From 3416303e15d4cfb2a791653e2721303a13d9aa61 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 29 Oct 2020 14:22:54 -0400 Subject: [PATCH 1/9] add auto refresh to all rules table --- .../components/last_updated/index.test.tsx | 47 +++++ .../common/components/last_updated/index.tsx | 83 +++++++++ .../components/last_updated/translations.ts | 15 ++ .../detection_engine/rules/all/columns.tsx | 2 +- .../detection_engine/rules/all/index.test.tsx | 83 ++++++--- .../detection_engine/rules/all/index.tsx | 164 ++++++++++-------- .../rules/all/utility_bar.test.tsx | 84 +++++++++ .../rules/all/utility_bar.tsx | 80 +++++++++ .../components/timeline/footer/index.tsx | 2 +- .../timeline/footer/last_updated.tsx | 68 -------- .../timeline/footer/translations.ts | 4 - 11 files changed, 465 insertions(+), 167 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx new file mode 100644 index 00000000000000..db42794448c533 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/index.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mount } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; + +import { LastUpdatedAt } from './'; + +describe('LastUpdatedAt', () => { + beforeEach(() => { + Date.now = jest.fn().mockReturnValue(1603995369774); + }); + + test('it renders correct relative time', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' Updated 2 minutes ago'); + }); + + test('it only renders icon if "compact" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(''); + expect(wrapper.find('[data-test-subj="last-updated-at-clock-icon"]').exists()).toBeTruthy(); + }); + + test('it renders updating text if "showUpdating" is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.text()).toEqual(' Updating...'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx b/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx new file mode 100644 index 00000000000000..ef4ff0123dd1cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/index.tsx @@ -0,0 +1,83 @@ +/* + * 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 { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import React, { useEffect, useMemo, useState } from 'react'; + +import * as i18n from './translations'; + +interface LastUpdatedAtProps { + compact?: boolean; + updatedAt: number; + showUpdating?: boolean; +} + +export const Updated = React.memo<{ date: number; prefix: string; updatedAt: number }>( + ({ date, prefix, updatedAt }) => ( + <> + {prefix} + { + + } + + ) +); + +Updated.displayName = 'Updated'; + +const prefix = ` ${i18n.UPDATED} `; + +export const LastUpdatedAt = React.memo( + ({ compact = false, updatedAt, showUpdating = false }) => { + const [date, setDate] = useState(Date.now()); + + function tick() { + setDate(Date.now()); + } + + useEffect(() => { + const timerID = setInterval(() => tick(), 10000); + return () => { + clearInterval(timerID); + }; + }, []); + + const updateText = useMemo(() => { + if (showUpdating) { + return {i18n.UPDATING}; + } + + if (!compact) { + return ; + } + + return null; + }, [compact, date, showUpdating, updatedAt]); + + return ( + + + + } + > + + + {updateText} + + + ); + } +); + +LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts b/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts new file mode 100644 index 00000000000000..77278563b24d5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/last_updated/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const UPDATING = i18n.translate('xpack.securitySolution.lastUpdated.updating', { + defaultMessage: 'Updating...', +}); + +export const UPDATED = i18n.translate('xpack.securitySolution.lastUpdated.updated', { + defaultMessage: 'Updated', +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 6800743db738eb..2b03d6dd4de364 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -210,7 +210,7 @@ export const getColumns = ({ getEmptyTagValue() ) : ( - + ); }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 1a4c2d405dca32..4353efb47e133a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -6,13 +6,14 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; +import { waitFor, act } from '@testing-library/react'; import '../../../../../common/mock/match_media'; import '../../../../../common/mock/formatted_relative'; import { TestProviders } from '../../../../../common/mock'; -import { waitFor } from '@testing-library/react'; import { AllRules } from './index'; import { useKibana } from '../../../../../common/lib/kibana'; +import { useRules, useRulesStatuses } from '../../../../containers/detection_engine/rules'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -27,6 +28,7 @@ jest.mock('react-router-dom', () => { jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../../common/lib/kibana'); +jest.mock('../../../../containers/detection_engine/rules'); const useKibanaMock = useKibana as jest.Mocked; @@ -84,9 +86,22 @@ jest.mock('./reducer', () => { }; }); -jest.mock('../../../../containers/detection_engine/rules', () => { +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { - useRules: jest.fn().mockReturnValue([ + ...originalModule, + useHistory: jest.fn(), + }; +}); + +describe('AllRules', () => { + const mockRefetchRulesData = jest.fn(); + + beforeEach(() => { + jest.useFakeTimers(); + + (useRules as jest.Mock).mockReturnValue([ false, { page: 1, @@ -126,8 +141,10 @@ jest.mock('../../../../containers/detection_engine/rules', () => { }, ], }, - ]), - useRulesStatuses: jest.fn().mockReturnValue({ + mockRefetchRulesData, + ]); + + (useRulesStatuses as jest.Mock).mockReturnValue({ loading: false, rulesStatuses: [ { @@ -150,21 +167,8 @@ jest.mock('../../../../containers/detection_engine/rules', () => { name: 'Test rule', }, ], - }), - }; -}); - -jest.mock('react-router-dom', () => { - const originalModule = jest.requireActual('react-router-dom'); - - return { - ...originalModule, - useHistory: jest.fn(), - }; -}); + }); -describe('AllRules', () => { - beforeEach(() => { useKibanaMock().services.application.capabilities = { navLinks: {}, management: {}, @@ -172,6 +176,11 @@ describe('AllRules', () => { actions: { show: true }, }; }); + + afterEach(() => { + jest.clearAllTimers(); + }); + it('renders correctly', () => { const wrapper = shallow( { }); describe('rules tab', () => { + it('it refreshes rule data every 30 seconds', async () => { + await act(async () => { + mount( + + + + ); + + await waitFor(() => { + jest.advanceTimersByTime(30000); + + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(15000); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(15000); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(2); + }); + }); + }); + it('renders correctly', async () => { const wrapper = mount( @@ -234,10 +274,11 @@ describe('AllRules', () => { /> ); - const monitoringTab = wrapper.find('[data-test-subj="allRulesTableTab-monitoring"] button'); - monitoringTab.simulate('click'); await waitFor(() => { + const monitoringTab = wrapper.find('[data-test-subj="allRulesTableTab-monitoring"] button'); + monitoringTab.simulate('click'); + wrapper.update(); expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeTruthy(); expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeFalsy(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 86b3daddd6c19b..49eeb3d2907d23 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -6,11 +6,11 @@ import { EuiBasicTable, - EuiContextMenuPanel, EuiLoadingContent, EuiSpacer, EuiTab, EuiTabs, + EuiProgress, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; @@ -27,13 +27,6 @@ import { RulesSortingFields, } from '../../../../containers/detection_engine/rules'; import { HeaderSection } from '../../../../../common/components/header_section'; -import { - UtilityBar, - UtilityBarAction, - UtilityBarGroup, - UtilityBarSection, - UtilityBarText, -} from '../../../../../common/components/utility_bar'; import { useKibana } from '../../../../../common/lib/kibana'; import { useStateToaster } from '../../../../../common/components/toasters'; import { Loader } from '../../../../../common/components/loader'; @@ -55,6 +48,8 @@ import { hasMlLicense } from '../../../../../../common/machine_learning/has_ml_l import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { isBoolean } from '../../../../../common/utils/privileges'; +import { AllRulesUtilityBar } from './utility_bar'; +import { LastUpdatedAt } from '../../../../../common/components/last_updated'; const INITIAL_SORT_FIELD = 'enabled'; const initialState: State = { @@ -128,6 +123,7 @@ export const AllRules = React.memo( setRefreshRulesData, }) => { const [initLoading, setInitLoading] = useState(true); + const [lastUpdated, setLastUpdated] = useState(Date.now()); const tableRef = useRef(); const [ { @@ -194,21 +190,19 @@ export const AllRules = React.memo( ]); const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), + (closePopover: () => void): JSX.Element[] => { + return getBatchItems({ + closePopover, + dispatch, + dispatchToaster, + hasMlPermissions, + hasActionsPrivileges, + loadingRuleIds, + selectedRuleIds, + reFetchRules: reFetchRulesData, + rules, + }); + }, [ dispatch, dispatchToaster, @@ -306,7 +300,7 @@ export const AllRules = React.memo( [loadingRuleIds] ); - const onFilterChangedCallback = useCallback((newFilterOptions: Partial) => { + const handleFilterChangedCallback = useCallback((newFilterOptions: Partial) => { dispatch({ type: 'updateFilterOptions', filterOptions: { @@ -328,13 +322,35 @@ export const AllRules = React.memo( return false; }, [loadingRuleIds, loadingRulesAction]); + const handleAllRulesTabClick = useCallback( + (tabId: AllRulesTabs) => () => { + setAllRulesTab(tabId); + }, + [] + ); + + const handleRefreshData = useCallback((): void => { + if (reFetchRulesData != null && !isLoadingAnActionOnRule) { + reFetchRulesData(true); + setLastUpdated(Date.now()); + } + }, [reFetchRulesData, isLoadingAnActionOnRule]); + + useEffect(() => { + const refreshTimerId = window.setInterval(() => handleRefreshData(), 30000); + + return () => { + clearInterval(refreshTimerId); + }; + }, [handleRefreshData]); + const tabs = useMemo( () => ( {allRulesTabs.map((tab) => ( setAllRulesTab(tab.id)} + onClick={handleAllRulesTabClick(tab.id)} isSelected={tab.id === allRulesTab} disabled={tab.disabled} key={tab.id} @@ -348,6 +364,20 @@ export const AllRules = React.memo( [allRulesTabs, allRulesTab, setAllRulesTab] ); + const shouldShowRulesTable = useMemo( + (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, + [initLoading, rulesCustomInstalled, rulesInstalled] + ); + + const shouldShowPrepackagedRulesPrompt = useMemo( + (): boolean => + rulesCustomInstalled != null && + rulesCustomInstalled === 0 && + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading, + [initLoading, prePackagedRuleStatus, rulesCustomInstalled] + ); + return ( <> ( <> - + {(isLoadingRules || isLoadingRulesStatuses) && ( + + )} + + } + > - {(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) && - !initLoading && ( - - )} - {rulesCustomInstalled != null && - rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading && ( - - )} + {isLoadingAnActionOnRule && !initLoading && ( + + )} + {shouldShowPrepackagedRulesPrompt && ( + + )} {initLoading && ( )} - {showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading && ( + {shouldShowRulesTable && ( <> - - - - - {i18n.SHOWING_RULES(pagination.total ?? 0)} - - - - - {i18n.SELECTED_RULES(selectedRuleIds.length)} - {!hasNoPermissions && ( - - {i18n.BATCH_ACTIONS} - - )} - reFetchRulesData(true)} - > - {i18n.REFRESH} - - - - + { + it('renders AllRulesUtilityBar total rules and selected rules', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="showingRules"]').at(0).text()).toEqual('Showing 4 rules'); + expect(wrapper.find('[data-test-subj="selectedRules"]').at(0).text()).toEqual( + 'Selected 1 rule' + ); + }); + + it('renders utility actions if user has permissions', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeTruthy(); + }); + + it('renders no utility actions if user has no permissions', () => { + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + expect(wrapper.find('[data-test-subj="bulkActions"]').exists()).toBeFalsy(); + }); + + it('invokes refresh on refresh action click', () => { + const mockRefresh = jest.fn(); + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + wrapper.find('[data-test-subj="refreshRulesAction"] button').at(0).simulate('click'); + + expect(mockRefresh).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx new file mode 100644 index 00000000000000..c6493bcd4caabc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiContextMenuPanel } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { + UtilityBar, + UtilityBarAction, + UtilityBarGroup, + UtilityBarSection, + UtilityBarText, +} from '../../../../../common/components/utility_bar'; +import * as i18n from '../translations'; + +interface AllRulesUtilityBarProps { + userHasNoPermissions: boolean; + numberSelectedRules: number; + paginationTotal: number; + onRefresh: (refreshRule: boolean) => void; + onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[]; +} + +export const AllRulesUtilityBar = React.memo( + ({ + userHasNoPermissions, + onRefresh, + paginationTotal, + numberSelectedRules, + onGetBatchItemsPopoverContent, + }) => { + const handleGetBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + + ), + [onGetBatchItemsPopoverContent] + ); + + return ( + + + + + {i18n.SHOWING_RULES(paginationTotal)} + + + + + + {i18n.SELECTED_RULES(numberSelectedRules)} + + {!userHasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} + + {i18n.REFRESH} + + + + + ); + } +); + +AllRulesUtilityBar.displayName = 'AllRulesUtilityBar'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 4119127d5a108d..f56d7d90cf2df8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -25,10 +25,10 @@ import styled from 'styled-components'; import { LoadingPanel } from '../../loading'; import { OnChangeItemsPerPage, OnChangePage } from '../events'; -import { LastUpdatedAt } from './last_updated'; import * as i18n from './translations'; import { useEventDetailsWidthContext } from '../../../../common/components/events_viewer/event_details_width_context'; import { useManageTimeline } from '../../manage_timeline'; +import { LastUpdatedAt } from '../../../../common/components/last_updated'; export const isCompactFooter = (width: number): boolean => width < 600; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx deleted file mode 100644 index 06ece50690c09c..00000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/last_updated.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; -import { FormattedRelative } from '@kbn/i18n/react'; -import React, { useEffect, useState } from 'react'; - -import * as i18n from './translations'; - -interface LastUpdatedAtProps { - compact?: boolean; - updatedAt: number; -} - -export const Updated = React.memo<{ date: number; prefix: string; updatedAt: number }>( - ({ date, prefix, updatedAt }) => ( - <> - {prefix} - { - - } - - ) -); - -Updated.displayName = 'Updated'; - -const prefix = ` ${i18n.UPDATED} `; - -export const LastUpdatedAt = React.memo(({ compact = false, updatedAt }) => { - const [date, setDate] = useState(Date.now()); - - function tick() { - setDate(Date.now()); - } - - useEffect(() => { - const timerID = setInterval(() => tick(), 10000); - return () => { - clearInterval(timerID); - }; - }, []); - - return ( - - - - } - > - - - {!compact ? : null} - - - ); -}); - -LastUpdatedAt.displayName = 'LastUpdatedAt'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts index f581d0757bc3cf..016406d6bd061b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/translations.ts @@ -36,10 +36,6 @@ export const TOTAL_COUNT_OF_EVENTS = i18n.translate( } ); -export const UPDATED = i18n.translate('xpack.securitySolution.footer.updated', { - defaultMessage: 'Updated', -}); - export const AUTO_REFRESH_ACTIVE = i18n.translate( 'xpack.securitySolution.footer.autoRefreshActiveDescription', { From f44b9de755c4a34d6776ffe19497b5ad318d47eb Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 29 Oct 2020 14:45:46 -0400 Subject: [PATCH 2/9] remove unused i18n --- .../pages/detection_engine/rules/all/index.test.tsx | 8 ++++---- .../detections/pages/detection_engine/rules/all/index.tsx | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 4353efb47e133a..e16a4face2fe13 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -201,7 +201,7 @@ describe('AllRules', () => { }); describe('rules tab', () => { - it('it refreshes rule data every 30 seconds', async () => { + it('it refreshes rule data every minute', async () => { await act(async () => { mount( @@ -221,12 +221,12 @@ describe('AllRules', () => { ); await waitFor(() => { - jest.advanceTimersByTime(30000); + jest.advanceTimersByTime(60000); expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(15000); + jest.advanceTimersByTime(30000); expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(15000); + jest.advanceTimersByTime(30000); expect(mockRefetchRulesData).toHaveBeenCalledTimes(2); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 49eeb3d2907d23..0f4c8a84f03d1d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -337,7 +337,7 @@ export const AllRules = React.memo( }, [reFetchRulesData, isLoadingAnActionOnRule]); useEffect(() => { - const refreshTimerId = window.setInterval(() => handleRefreshData(), 30000); + const refreshTimerId = window.setInterval(() => handleRefreshData(), 60000); return () => { clearInterval(refreshTimerId); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1492c8a03906a5..c335ddb25e4c42 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17864,7 +17864,6 @@ "xpack.securitySolution.footer.of": "/", "xpack.securitySolution.footer.rows": "行", "xpack.securitySolution.footer.totalCountOfEvents": "イベント", - "xpack.securitySolution.footer.updated": "更新しました", "xpack.securitySolution.formatted.duration.aFewMillisecondsTooltip": "数ミリ秒", "xpack.securitySolution.formatted.duration.aFewNanosecondsTooltip": "数ナノ秒", "xpack.securitySolution.formatted.duration.aMillisecondTooltip": "1 ミリ秒", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index be5bcd3cf0543d..d72b22be3469f0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17883,7 +17883,6 @@ "xpack.securitySolution.footer.of": "/", "xpack.securitySolution.footer.rows": "行", "xpack.securitySolution.footer.totalCountOfEvents": "事件", - "xpack.securitySolution.footer.updated": "已更新", "xpack.securitySolution.formatted.duration.aFewMillisecondsTooltip": "几毫秒", "xpack.securitySolution.formatted.duration.aFewNanosecondsTooltip": "几纳秒", "xpack.securitySolution.formatted.duration.aMillisecondTooltip": "一毫秒", From 53cfe5f004dc5b3a6e0d63ab95f6bcb40c500c99 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 29 Oct 2020 17:46:07 -0400 Subject: [PATCH 3/9] updated per feedback --- .../components/header_section/index.tsx | 4 +- .../detection_engine/rules/all/index.tsx | 28 ++++++++++++-- .../rules_table_filters.tsx | 37 ++++++++++++++++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index df655bf179f5b8..da59ab56fd3fbc 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -45,6 +45,7 @@ export interface HeaderSectionProps extends HeaderProps { title: string | React.ReactNode; titleSize?: EuiTitleSize; tooltip?: string; + growLeftSplit?: boolean; } const HeaderSectionComponent: React.FC = ({ @@ -57,10 +58,11 @@ const HeaderSectionComponent: React.FC = ({ title, titleSize = 'm', tooltip, + growLeftSplit = true, }) => (
- + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index 0f4c8a84f03d1d..cad6714e465ea5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -143,6 +143,7 @@ export const AllRules = React.memo( const mlCapabilities = useMlCapabilities(); const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const refreshInterval = useRef(null); // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); @@ -329,6 +330,12 @@ export const AllRules = React.memo( [] ); + const handleClearInterval = useCallback(() => { + if (refreshInterval.current != null) { + clearInterval(refreshInterval.current); + } + }, []); + const handleRefreshData = useCallback((): void => { if (reFetchRulesData != null && !isLoadingAnActionOnRule) { reFetchRulesData(true); @@ -336,13 +343,22 @@ export const AllRules = React.memo( } }, [reFetchRulesData, isLoadingAnActionOnRule]); - useEffect(() => { - const refreshTimerId = window.setInterval(() => handleRefreshData(), 60000); + const handleRefreshDataInterval = useCallback( + (interval) => { + handleClearInterval(); + if (interval > 0) { + refreshInterval.current = window.setInterval(() => handleRefreshData(), interval); + } + }, + [handleRefreshData, handleClearInterval] + ); + + useEffect(() => { return () => { - clearInterval(refreshTimerId); + handleClearInterval(); }; - }, [handleRefreshData]); + }, [handleClearInterval]); const tabs = useMemo( () => ( @@ -413,6 +429,7 @@ export const AllRules = React.memo( )} ( onFilterChanged={handleFilterChangedCallback} rulesCustomInstalled={rulesCustomInstalled} rulesInstalled={rulesInstalled} + isLoading={loading || isLoadingRules || isLoadingRulesStatuses} + onRefresh={handleRefreshData} + onIntervalChange={handleRefreshDataInterval} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index 0f201fcbaa441a..c8c436622c4588 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -12,8 +12,11 @@ import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, + EuiSuperDatePicker, + OnRefreshChangeProps, } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; + import * as i18n from '../../translations'; import { FilterOptions } from '../../../../../containers/detection_engine/rules'; @@ -24,6 +27,9 @@ interface RulesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; rulesCustomInstalled: number | null; rulesInstalled: number | null; + isLoading: boolean; + onRefresh: () => void; + onIntervalChange: (arg: number) => void; } /** @@ -36,12 +42,17 @@ const RulesTableFiltersComponent = ({ onFilterChanged, rulesCustomInstalled, rulesInstalled, + isLoading, + onRefresh, + onIntervalChange, }: RulesTableFiltersProps) => { const [filter, setFilter] = useState(''); const [selectedTags, setSelectedTags] = useState([]); const [showCustomRules, setShowCustomRules] = useState(false); const [showElasticRules, setShowElasticRules] = useState(false); const [isLoadingTags, tags, reFetchTags] = useTags(); + const [isRefreshPaused, setIsPaused] = useState(true); + const [interval, setRefreshInterval] = useState(0); useEffect(() => { reFetchTags(); @@ -74,9 +85,22 @@ const RulesTableFiltersComponent = ({ [selectedTags] ); + const handleRefreshChange = useCallback( + ({ isPaused, refreshInterval }: OnRefreshChangeProps) => { + setIsPaused(isPaused); + setRefreshInterval(refreshInterval); + if (!isPaused) { + onIntervalChange(refreshInterval); + } + }, + [onIntervalChange] + ); + + const NOOP = useCallback(() => {}, []); + return ( - + + + + ); }; From 9301afe1903e4ec4c7d7a8bdcdb0135f40625bac Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Sat, 31 Oct 2020 00:41:21 -0400 Subject: [PATCH 4/9] wip - updated auto refresh to work alongside localstorage --- .../detection_engine/rules/all/index.test.tsx | 117 +++++------ .../detection_engine/rules/all/index.tsx | 189 +++++++++++++----- .../detection_engine/rules/all/reducer.ts | 35 +++- .../rules_table_filters.tsx | 23 +-- .../detection_engine/rules/translations.ts | 21 ++ 5 files changed, 259 insertions(+), 126 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index e16a4face2fe13..46bc6cc57f8bf2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -32,68 +32,59 @@ jest.mock('../../../../containers/detection_engine/rules'); const useKibanaMock = useKibana as jest.Mocked; -jest.mock('./reducer', () => { - return { - allRulesReducer: jest.fn().mockReturnValue(() => ({ - exportRuleIds: [], - filterOptions: { - filter: 'some filter', - sortField: 'some sort field', - sortOrder: 'desc', - }, - loadingRuleIds: [], - loadingRulesAction: null, - pagination: { - page: 1, - perPage: 20, - total: 1, - }, - rules: [ - { - actions: [], - created_at: '2020-02-14T19:49:28.178Z', - created_by: 'elastic', - description: 'jibber jabber', - enabled: false, - false_positives: [], - filters: [], - from: 'now-660s', - id: 'rule-id-1', - immutable: true, - index: ['endgame-*'], - interval: '10m', - language: 'kuery', - max_signals: 100, - name: 'Credential Dumping - Detected - Elastic Endpoint', - output_index: '.siem-signals-default', - query: 'host.name:*', - references: [], - risk_score: 73, - rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', - severity: 'high', - tags: ['Elastic', 'Endpoint'], - threat: [], - throttle: null, - to: 'now', - type: 'query', - updated_at: '2020-02-14T19:49:28.320Z', - updated_by: 'elastic', - version: 1, - }, - ], - selectedRuleIds: [], - })), - }; -}); - -jest.mock('react-router-dom', () => { - const originalModule = jest.requireActual('react-router-dom'); - - return { - ...originalModule, - useHistory: jest.fn(), - }; -}); +// jest.mock('./reducer', () => { +// return { +// allRulesReducer: jest.fn().mockReturnValue(() => ({ +// exportRuleIds: [], +// filterOptions: { +// filter: 'some filter', +// sortField: 'some sort field', +// sortOrder: 'desc', +// }, +// loadingRuleIds: [], +// loadingRulesAction: null, +// pagination: { +// page: 1, +// perPage: 20, +// total: 1, +// }, +// rules: [ +// { +// actions: [], +// created_at: '2020-02-14T19:49:28.178Z', +// created_by: 'elastic', +// description: 'jibber jabber', +// enabled: false, +// false_positives: [], +// filters: [], +// from: 'now-660s', +// id: 'rule-id-1', +// immutable: true, +// index: ['endgame-*'], +// interval: '10m', +// language: 'kuery', +// max_signals: 100, +// name: 'Credential Dumping - Detected - Elastic Endpoint', +// output_index: '.siem-signals-default', +// query: 'host.name:*', +// references: [], +// risk_score: 73, +// rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', +// severity: 'high', +// tags: ['Elastic', 'Endpoint'], +// threat: [], +// throttle: null, +// to: 'now', +// type: 'query', +// updated_at: '2020-02-14T19:49:28.320Z', +// updated_by: 'elastic', +// version: 1, +// }, +// ], +// selectedRuleIds: [], +// })), +// }; +// }); describe('AllRules', () => { const mockRefetchRulesData = jest.fn(); @@ -201,7 +192,7 @@ describe('AllRules', () => { }); describe('rules tab', () => { - it('it refreshes rule data every minute', async () => { + xit('it refreshes rule data every minute', async () => { await act(async () => { mount( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index cad6714e465ea5..33d4a994b34bf7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -11,6 +11,9 @@ import { EuiTab, EuiTabs, EuiProgress, + EuiOverlayMask, + EuiConfirmModal, + OnRefreshChangeProps, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; @@ -50,6 +53,7 @@ import { useFormatUrl } from '../../../../../common/components/link_to'; import { isBoolean } from '../../../../../common/utils/privileges'; import { AllRulesUtilityBar } from './utility_bar'; import { LastUpdatedAt } from '../../../../../common/components/last_updated'; +import { APP_ID } from '../../../../../../common/constants'; const INITIAL_SORT_FIELD = 'enabled'; const initialState: State = { @@ -68,6 +72,10 @@ const initialState: State = { }, rules: [], selectedRuleIds: [], + lastUpdated: 0, + showIdleModal: false, + isRefreshPaused: true, + intervalValue: 0, }; interface AllRulesProps { @@ -123,7 +131,6 @@ export const AllRules = React.memo( setRefreshRulesData, }) => { const [initLoading, setInitLoading] = useState(true); - const [lastUpdated, setLastUpdated] = useState(Date.now()); const tableRef = useRef(); const [ { @@ -134,16 +141,28 @@ export const AllRules = React.memo( pagination, rules, selectedRuleIds, + lastUpdated, + showIdleModal, + isRefreshPaused, + intervalValue, }, dispatch, - ] = useReducer(allRulesReducer(tableRef), initialState); + ] = useReducer(allRulesReducer(tableRef), { ...initialState, lastUpdated: Date.now() }); const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); const mlCapabilities = useMlCapabilities(); const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const { formatUrl } = useFormatUrl(SecurityPageName.detections); - const refreshInterval = useRef(null); + + // Auto rules info refresh refs + const idleTimeoutId = useRef(null); + + const handleClearIdleTimeout = useCallback(() => { + if (idleTimeoutId.current != null) { + clearTimeout(idleTimeoutId.current); + } + }, []); // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); @@ -156,6 +175,33 @@ export const AllRules = React.memo( }); }, []); + const setShowIdleModal = useCallback((show: boolean) => { + dispatch({ + type: 'setShowIdleModal', + show, + }); + }, []); + + const setAutoRefreshPaused = useCallback((paused: boolean) => { + dispatch({ + type: 'setAutoRefreshPaused', + paused, + }); + }, []); + + const setAutoRefreshInterval = useCallback((interval: number) => { + dispatch({ + type: 'setAutoRefreshInterval', + interval, + }); + }, []); + + const setLastRefreshDate = useCallback(() => { + dispatch({ + type: 'setLastRefreshDate', + }); + }, []); + const [isLoadingRules, , reFetchRulesData] = useRules({ pagination, filterOptions, @@ -183,6 +229,7 @@ export const AllRules = React.memo( application: { capabilities: { actions }, }, + storage, }, } = useKibana(); @@ -301,7 +348,7 @@ export const AllRules = React.memo( [loadingRuleIds] ); - const handleFilterChangedCallback = useCallback((newFilterOptions: Partial) => { + const onFilterChangedCallback = useCallback((newFilterOptions: Partial) => { dispatch({ type: 'updateFilterOptions', filterOptions: { @@ -323,42 +370,87 @@ export const AllRules = React.memo( return false; }, [loadingRuleIds, loadingRulesAction]); - const handleAllRulesTabClick = useCallback( - (tabId: AllRulesTabs) => () => { - setAllRulesTab(tabId); - }, - [] - ); + const handleResetIdleTimer = useCallback((): void => { + handleClearIdleTimeout(); - const handleClearInterval = useCallback(() => { - if (refreshInterval.current != null) { - clearInterval(refreshInterval.current); + if (!isRefreshPaused) { + idleTimeoutId.current = setTimeout(() => { + setShowIdleModal(true); + }, 1800000); } - }, []); - - const handleRefreshData = useCallback((): void => { - if (reFetchRulesData != null && !isLoadingAnActionOnRule) { - reFetchRulesData(true); - setLastUpdated(Date.now()); - } - }, [reFetchRulesData, isLoadingAnActionOnRule]); + }, [setShowIdleModal, handleClearIdleTimeout, isRefreshPaused]); + + const handleIdleModalContinue = useCallback((): void => { + setShowIdleModal(false); + }, [setShowIdleModal]); + + const handleRefreshData = useCallback( + (forceRefresh = false): void => { + if ( + reFetchRulesData != null && + !isLoadingAnActionOnRule && + (!isRefreshPaused || forceRefresh) + ) { + reFetchRulesData(true); + setLastRefreshDate(); + } + }, + [reFetchRulesData, isLoadingAnActionOnRule, isRefreshPaused, setLastRefreshDate] + ); const handleRefreshDataInterval = useCallback( - (interval) => { - handleClearInterval(); + ({ isPaused, refreshInterval: interval }: OnRefreshChangeProps) => { + setAutoRefreshPaused(isPaused); + setAutoRefreshInterval(interval); + + // refresh data when refresh interval is activated + if (isRefreshPaused && !isPaused && interval > 0) { + handleRefreshData(true); + storage.set(`${APP_ID}-rulesRefreshInterval`, [false, interval]); + } - if (interval > 0) { - refreshInterval.current = window.setInterval(() => handleRefreshData(), interval); + if (isPaused) { + storage.set(`${APP_ID}-rulesRefreshInterval`, [true, 0]); } }, - [handleRefreshData, handleClearInterval] + [handleRefreshData, isRefreshPaused, setAutoRefreshInterval, setAutoRefreshPaused, storage] ); + // on initial render, want to check if user has any interval info + useEffect(() => { + const [isStoredRefreshPaused, storedRefreshInterval] = storage.get( + `${APP_ID}-rulesRefreshInterval` + ); + setAutoRefreshPaused(storedRefreshInterval <= 0 ? true : isStoredRefreshPaused); + setAutoRefreshInterval(storedRefreshInterval); + window.addEventListener('mousemove', handleResetIdleTimer, { passive: true }); + window.addEventListener('keydown', handleResetIdleTimer); + }, [storage, handleResetIdleTimer, setAutoRefreshPaused, setAutoRefreshInterval]); + + // want to update local storage on cleanup, as opposed to + // updating every time interval is changed useEffect(() => { return () => { - handleClearInterval(); + handleClearIdleTimeout(); + + window.removeEventListener('mousemove', handleResetIdleTimer); + window.removeEventListener('keydown', handleResetIdleTimer); }; - }, [handleClearInterval]); + }, [handleClearIdleTimeout, handleResetIdleTimer]); + + const shouldShowRulesTable = useMemo( + (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, + [initLoading, rulesCustomInstalled, rulesInstalled] + ); + + const shouldShowPrepackagedRulesPrompt = useMemo( + (): boolean => + rulesCustomInstalled != null && + rulesCustomInstalled === 0 && + prePackagedRuleStatus === 'ruleNotInstalled' && + !initLoading, + [initLoading, prePackagedRuleStatus, rulesCustomInstalled] + ); const tabs = useMemo( () => ( @@ -366,7 +458,7 @@ export const AllRules = React.memo( {allRulesTabs.map((tab) => ( setAllRulesTab(tab.id)} isSelected={tab.id === allRulesTab} disabled={tab.disabled} key={tab.id} @@ -380,20 +472,6 @@ export const AllRules = React.memo( [allRulesTabs, allRulesTab, setAllRulesTab] ); - const shouldShowRulesTable = useMemo( - (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, - [initLoading, rulesCustomInstalled, rulesInstalled] - ); - - const shouldShowPrepackagedRulesPrompt = useMemo( - (): boolean => - rulesCustomInstalled != null && - rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading, - [initLoading, prePackagedRuleStatus, rulesCustomInstalled] - ); - return ( <> ( {tabs} - + <> {(isLoadingRules || isLoadingRulesStatuses) && ( ( } > ( {initLoading && ( )} + {showIdleModal && ( + + +

{i18n.REFRESH_PROMPT_BODY}

+
+
+ )} {shouldShowRulesTable && ( <> ; pagination: Partial; } - | { type: 'failure' }; + | { type: 'failure' } + | { type: 'setLastRefreshDate' } + | { type: 'setShowIdleModal'; show: boolean } + | { type: 'setAutoRefreshPaused'; paused: boolean } + | { type: 'setAutoRefreshInterval'; interval: number }; export const allRulesReducer = ( tableRef: React.MutableRefObject | undefined> @@ -126,6 +134,31 @@ export const allRulesReducer = ( rules: [], }; } + case 'setLastRefreshDate': { + return { + ...state, + lastUpdated: Date.now(), + }; + } + case 'setShowIdleModal': { + return { + ...state, + showIdleModal: action.show, + isRefreshPaused: action.show, + }; + } + case 'setAutoRefreshPaused': { + return { + ...state, + isRefreshPaused: action.paused, + }; + } + case 'setAutoRefreshInterval': { + return { + ...state, + intervalValue: action.interval, + }; + } default: return state; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index c8c436622c4588..c5656e9888e1ae 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -28,8 +28,10 @@ interface RulesTableFiltersProps { rulesCustomInstalled: number | null; rulesInstalled: number | null; isLoading: boolean; + isRefreshPaused: boolean; + refreshInterval: number; onRefresh: () => void; - onIntervalChange: (arg: number) => void; + onIntervalChange: (arg: OnRefreshChangeProps) => void; } /** @@ -42,6 +44,8 @@ const RulesTableFiltersComponent = ({ onFilterChanged, rulesCustomInstalled, rulesInstalled, + isRefreshPaused, + refreshInterval, isLoading, onRefresh, onIntervalChange, @@ -51,8 +55,6 @@ const RulesTableFiltersComponent = ({ const [showCustomRules, setShowCustomRules] = useState(false); const [showElasticRules, setShowElasticRules] = useState(false); const [isLoadingTags, tags, reFetchTags] = useTags(); - const [isRefreshPaused, setIsPaused] = useState(true); - const [interval, setRefreshInterval] = useState(0); useEffect(() => { reFetchTags(); @@ -85,17 +87,6 @@ const RulesTableFiltersComponent = ({ [selectedTags] ); - const handleRefreshChange = useCallback( - ({ isPaused, refreshInterval }: OnRefreshChangeProps) => { - setIsPaused(isPaused); - setRefreshInterval(refreshInterval); - if (!isPaused) { - onIntervalChange(refreshInterval); - } - }, - [onIntervalChange] - ); - const NOOP = useCallback(() => {}, []); return ( @@ -150,8 +141,8 @@ const RulesTableFiltersComponent = ({ isPaused={isRefreshPaused} onTimeChange={NOOP} onRefresh={onRefresh} - onRefreshChange={handleRefreshChange} - refreshInterval={interval} + onRefreshChange={onIntervalChange} + refreshInterval={refreshInterval} isAutoRefreshOnly />
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index d20b97a98fbf5b..a8f4ea82d5ae3f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -554,3 +554,24 @@ export const IMPORT_FAILED_DETAILED = (ruleId: string, statusCode: number, messa defaultMessage: 'Rule ID: {ruleId}\n Status Code: {statusCode}\n Message: {message}', } ); + +export const REFRESH_PROMPT_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptTitle', + { + defaultMessage: 'Are you still there?', + } +); + +export const REFRESH_PROMPT_CONFIRM = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptConfirm', + { + defaultMessage: 'Continue', + } +); + +export const REFRESH_PROMPT_BODY = i18n.translate( + 'xpack.securitySolution.detectionEngine.components.allRules.refreshPromptBody', + { + defaultMessage: 'Rule auto-refresh has been paused. Click "Continue" to resume.', + } +); From f79307f0be56bd7842448c2a7c35da941a176f60 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 3 Nov 2020 11:57:45 -0500 Subject: [PATCH 5/9] fixes some bugs and adds cypress and unit tests --- .../alerts_detection_rules.spec.ts | 42 +++ .../cypress/screens/alerts_detection_rules.ts | 21 +- .../cypress/tasks/alerts_detection_rules.ts | 48 ++- .../__snapshots__/index.test.tsx.snap | 4 +- .../detection_engine/rules/all/index.test.tsx | 256 +++++++++------ .../detection_engine/rules/all/index.tsx | 88 +++--- .../rules/all/reducer.test.ts | 295 ++++++++++++++++++ .../detection_engine/rules/all/reducer.ts | 40 ++- .../rules_table_filters.test.tsx | 53 +++- .../rules_table_filters.tsx | 7 +- .../detection_engine/rules/translations.ts | 28 ++ 11 files changed, 721 insertions(+), 161 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.test.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index 3fa304ab7cf190..b4c108b658e28f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -10,6 +10,7 @@ import { RULE_SWITCH, SECOND_RULE, SEVENTH_RULE, + RULE_AUTO_REFRESH_IDLE_MODAL, } from '../screens/alerts_detection_rules'; import { @@ -19,6 +20,11 @@ import { } from '../tasks/alerts'; import { activateRule, + checkAllRulesIdleModal, + checkAutoRefresh, + dismissAllRulesIdleModal, + resetAllRulesIdleModalTimeout, + setAllRulesAutoRefreshIntervalInSeconds, sortByActivatedRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRuleToBeActivated, @@ -35,6 +41,7 @@ describe('Alerts detection rules', () => { after(() => { esArchiverUnload('prebuilt_rules_loaded'); + cy.clock().invoke('restore'); }); it('Sorts by activated rules', () => { @@ -75,4 +82,39 @@ describe('Alerts detection rules', () => { }); }); }); + + it('Displays idle modal if auto refresh is on', () => { + cy.clock(Date.now(), ['setTimeout']); + + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + + // idle modal should not show if refresh interval is not set + checkAllRulesIdleModal('not.be.visible'); + + // idle modal should show if refresh interval is set + setAllRulesAutoRefreshIntervalInSeconds(30); + + // mock 30 seconds passing to make sure refresh + // is conducted + checkAutoRefresh(30000, 'be.visible'); + + // mock 45 minutes passing to check that idle modal shows + // and refreshing is paused + checkAllRulesIdleModal('be.visible'); + checkAutoRefresh(30000, 'not.be.visible'); + + // clicking on modal to continue, should resume refreshing + dismissAllRulesIdleModal(); + checkAutoRefresh(30000, 'be.visible'); + + // if mouse movement detected, idle modal should not + // show after 45 min + cy.clock().invoke('restore'); + resetAllRulesIdleModalTimeout(); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.exist'); + }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 0d0ea8460edf11..8d1e5742cd4ec9 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -10,7 +10,7 @@ export const CREATE_NEW_RULE_BTN = '[data-test-subj="create-new-rule"]'; export const COLLAPSED_ACTION_BTN = '[data-test-subj="euiCollapsedItemActionsButton"]'; -export const CUSTOM_RULES_BTN = '[data-test-subj="show-custom-rules-filter-button"]'; +export const CUSTOM_RULES_BTN = '[data-test-subj="showCustomRulesFilterButton"]'; export const DELETE_RULE_ACTION_BTN = '[data-test-subj="deleteRuleAction"]'; @@ -18,7 +18,7 @@ export const EDIT_RULE_ACTION_BTN = '[data-test-subj="editRuleAction"]'; export const DELETE_RULE_BULK_BTN = '[data-test-subj="deleteRuleBulk"]'; -export const ELASTIC_RULES_BTN = '[data-test-subj="show-elastic-rules-filter-button"]'; +export const ELASTIC_RULES_BTN = '[data-test-subj="showElasticRulesFilterButton"]'; export const EXPORT_ACTION_BTN = '[data-test-subj="exportRuleAction"]'; @@ -31,7 +31,7 @@ export const LOAD_PREBUILT_RULES_BTN = '[data-test-subj="load-prebuilt-rules"]'; export const LOADING_INITIAL_PREBUILT_RULES_TABLE = '[data-test-subj="initialLoadingPanelAllRulesTable"]'; -export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]'; +export const ASYNC_LOADING_PROGRESS = '[data-test-subj="loadingRulesInfoProgress"]'; export const NEXT_BTN = '[data-test-subj="pagination-button-next"]'; @@ -64,3 +64,18 @@ export const SHOWING_RULES_TEXT = '[data-test-subj="showingRules"]'; export const SORT_RULES_BTN = '[data-test-subj="tableHeaderSortButton"]'; export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]'; + +export const ALL_RULES_PANEL = '[data-test-subj="allRulesPanel"]'; + +export const RULE_AUTO_REFRESH_BUTTON = + 'button[data-test-subj="superDatePickerToggleQuickMenuButton"]'; + +export const RULE_AUTO_REFRESH_INPUT = + 'input[data-test-subj="superDatePickerRefreshIntervalInput"]'; + +export const RULE_AUTO_REFRESH_START_STOP_BUTTON = + '[data-test-subj="superDatePickerToggleRefreshButton"]'; + +export const RULE_AUTO_REFRESH_IDLE_MODAL = '[data-test-subj="allRulesIdleModal"]'; + +export const RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE = '[data-test-subj="allRulesIdleModal"] button'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 1c430e12b6b734..aa4c02ab6ed577 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -13,7 +13,6 @@ import { DELETE_RULE_BULK_BTN, LOAD_PREBUILT_RULES_BTN, LOADING_INITIAL_PREBUILT_RULES_TABLE, - LOADING_SPINNER, PAGINATION_POPOVER_BTN, RELOAD_PREBUILT_RULES_BTN, RULE_CHECKBOX, @@ -26,6 +25,12 @@ import { EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, NEXT_BTN, + RULE_AUTO_REFRESH_BUTTON, + RULE_AUTO_REFRESH_INPUT, + ALL_RULES_PANEL, + ASYNC_LOADING_PROGRESS, + RULE_AUTO_REFRESH_IDLE_MODAL, + RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; @@ -66,8 +71,8 @@ export const exportFirstRule = () => { export const filterByCustomRules = () => { cy.get(CUSTOM_RULES_BTN).click({ force: true }); - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('not.exist'); }; export const goToCreateNewRule = () => { @@ -119,6 +124,39 @@ export const waitForRuleToBeActivated = () => { }; export const waitForRulesToBeLoaded = () => { - cy.get(LOADING_SPINNER).should('exist'); - cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('exist'); + cy.get(ASYNC_LOADING_PROGRESS).should('not.exist'); +}; + +export const setAllRulesAutoRefreshIntervalInSeconds = (interval: number) => { + cy.get(RULE_AUTO_REFRESH_BUTTON).eq(1).should('exist').click({ force: true, multiple: true }); + cy.get(RULE_AUTO_REFRESH_INPUT).type('{selectall}').type(`${interval}{enter}`); + cy.get(ALL_RULES_PANEL).first().click({ force: true, multiple: true }); +}; + +// when using, ensure you've called cy.clock prior in test +export const checkAutoRefresh = (ms: number, condition: string) => { + cy.get(ASYNC_LOADING_PROGRESS).should('not.be.visible'); + cy.tick(ms); + cy.get(ASYNC_LOADING_PROGRESS).should(condition); +}; + +export const dismissAllRulesIdleModal = () => { + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE) + .eq(1) + .should('exist') + .click({ force: true, multiple: true }); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.be.visible'); +}; + +export const checkAllRulesIdleModal = (condition: string) => { + cy.tick(2700000); + cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should(condition); +}; + +export const resetAllRulesIdleModalTimeout = () => { + cy.clock(Date.now(), ['setTimeout']); + cy.tick(2000000); + cy.window().trigger('mousemove', { force: true }); + cy.tick(700000); }; diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index f2d2d23d60fb13..d3d20c71835707 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -7,7 +7,9 @@ exports[`HeaderSection it renders 1`] = ` - + { const original = jest.requireActual('react-router-dom'); @@ -32,62 +41,17 @@ jest.mock('../../../../containers/detection_engine/rules'); const useKibanaMock = useKibana as jest.Mocked; -// jest.mock('./reducer', () => { -// return { -// allRulesReducer: jest.fn().mockReturnValue(() => ({ -// exportRuleIds: [], -// filterOptions: { -// filter: 'some filter', -// sortField: 'some sort field', -// sortOrder: 'desc', -// }, -// loadingRuleIds: [], -// loadingRulesAction: null, -// pagination: { -// page: 1, -// perPage: 20, -// total: 1, -// }, -// rules: [ -// { -// actions: [], -// created_at: '2020-02-14T19:49:28.178Z', -// created_by: 'elastic', -// description: 'jibber jabber', -// enabled: false, -// false_positives: [], -// filters: [], -// from: 'now-660s', -// id: 'rule-id-1', -// immutable: true, -// index: ['endgame-*'], -// interval: '10m', -// language: 'kuery', -// max_signals: 100, -// name: 'Credential Dumping - Detected - Elastic Endpoint', -// output_index: '.siem-signals-default', -// query: 'host.name:*', -// references: [], -// risk_score: 73, -// rule_id: '571afc56-5ed9-465d-a2a9-045f099f6e7e', -// severity: 'high', -// tags: ['Elastic', 'Endpoint'], -// threat: [], -// throttle: null, -// to: 'now', -// type: 'query', -// updated_at: '2020-02-14T19:49:28.320Z', -// updated_by: 'elastic', -// version: 1, -// }, -// ], -// selectedRuleIds: [], -// })), -// }; -// }); - describe('AllRules', () => { const mockRefetchRulesData = jest.fn(); + const state: State = mockGlobalState; + const { storage } = createSecuritySolutionStorageMock(); + const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { jest.useFakeTimers(); @@ -170,6 +134,7 @@ describe('AllRules', () => { afterEach(() => { jest.clearAllTimers(); + jest.clearAllMocks(); }); it('renders correctly', () => { @@ -192,40 +157,157 @@ describe('AllRules', () => { }); describe('rules tab', () => { - xit('it refreshes rule data every minute', async () => { - await act(async () => { - mount( - - - - ); - - await waitFor(() => { - jest.advanceTimersByTime(60000); - - expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(30000); - expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(30000); - expect(mockRefetchRulesData).toHaveBeenCalledTimes(2); - }); + it('it pulls from storage to determine if an auto refresh interval is set', async () => { + useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ + false, + 1800000, + ]); + + mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1800000); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); + }); + }); + + it('it pulls from storage and does not set an auto refresh interval if storage indicates refresh is paused', async () => { + useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ + true, + 1800000, + ]); + + mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1800000); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + }); + }); + + it('it updates storage with auto refresh selection on component unmount', async () => { + useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ + false, + 1800000, + ]); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper + .find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button') + .first() + .simulate('click'); + + wrapper + .find('input[data-test-subj="superDatePickerRefreshIntervalInput"]') + .simulate('change', { target: { value: '2' } }); + + wrapper + .find('[data-test-subj="superDatePickerToggleRefreshButton"]') + .first() + .simulate('click'); + + wrapper.unmount(); + wrapper.mount(); + + expect( + useKibanaMock().services.storage.get(`${APP_ID}.detections.allRules.timeRefresh`) + ).toEqual([false, 1800000]); + }); + }); + + it('it stops auto refreshing when user hits "Stop"', async () => { + useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ + false, + 1800000, + ]); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + wrapper + .find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button') + .first() + .simulate('click'); + + wrapper + .find('[data-test-subj="superDatePickerToggleRefreshButton"]') + .first() + .simulate('click'); + + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1800000); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); }); }); - it('renders correctly', async () => { + it('renders all rules tab by default', async () => { const wrapper = mount( - + { it('renders monitoring tab when monitoring tab clicked', async () => { const wrapper = mount( - + ( if (!isRefreshPaused) { idleTimeoutId.current = setTimeout(() => { setShowIdleModal(true); - }, 1800000); + }, 2700000); } }, [setShowIdleModal, handleClearIdleTimeout, isRefreshPaused]); @@ -398,43 +399,53 @@ export const AllRules = React.memo( [reFetchRulesData, isLoadingAnActionOnRule, isRefreshPaused, setLastRefreshDate] ); + const setLocalStorage = useCallback( + (paused: boolean, newInterval: number) => { + storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [paused, newInterval]); + }, + [storage] + ); + + const debounceSetLocalStorage = useMemo(() => debounce(500, setLocalStorage), [ + setLocalStorage, + ]); + const handleRefreshDataInterval = useCallback( ({ isPaused, refreshInterval: interval }: OnRefreshChangeProps) => { setAutoRefreshPaused(isPaused); setAutoRefreshInterval(interval); // refresh data when refresh interval is activated - if (isRefreshPaused && !isPaused && interval > 0) { - handleRefreshData(true); - storage.set(`${APP_ID}-rulesRefreshInterval`, [false, interval]); - } - - if (isPaused) { - storage.set(`${APP_ID}-rulesRefreshInterval`, [true, 0]); + if (interval > 0) { + debounceSetLocalStorage(isPaused, interval); } }, - [handleRefreshData, isRefreshPaused, setAutoRefreshInterval, setAutoRefreshPaused, storage] + [debounceSetLocalStorage, setAutoRefreshInterval, setAutoRefreshPaused] ); // on initial render, want to check if user has any interval info + useEffect((): void => { + if (initLoading) { + const [isStoredRefreshPaused, storedRefreshInterval] = storage.get( + `${APP_ID}.detections.allRules.timeRefresh` + ) ?? [true, 0]; + setAutoRefreshPaused(isStoredRefreshPaused); + setAutoRefreshInterval(storedRefreshInterval); + } + }, [initLoading, setAutoRefreshInterval, setAutoRefreshPaused, storage]); + useEffect(() => { - const [isStoredRefreshPaused, storedRefreshInterval] = storage.get( - `${APP_ID}-rulesRefreshInterval` - ); - setAutoRefreshPaused(storedRefreshInterval <= 0 ? true : isStoredRefreshPaused); - setAutoRefreshInterval(storedRefreshInterval); - window.addEventListener('mousemove', handleResetIdleTimer, { passive: true }); - window.addEventListener('keydown', handleResetIdleTimer); - }, [storage, handleResetIdleTimer, setAutoRefreshPaused, setAutoRefreshInterval]); - - // want to update local storage on cleanup, as opposed to - // updating every time interval is changed - useEffect(() => { + handleResetIdleTimer(); + + const fetchSuggestions = debounce(500, handleResetIdleTimer); + + window.addEventListener('mousemove', fetchSuggestions, { passive: true }); + window.addEventListener('keydown', fetchSuggestions); + return () => { handleClearIdleTimeout(); - - window.removeEventListener('mousemove', handleResetIdleTimer); - window.removeEventListener('keydown', handleResetIdleTimer); + window.removeEventListener('mousemove', fetchSuggestions); + window.removeEventListener('keydown', fetchSuggestions); }; }, [handleClearIdleTimeout, handleResetIdleTimer]); @@ -452,6 +463,22 @@ export const AllRules = React.memo( [initLoading, prePackagedRuleStatus, rulesCustomInstalled] ); + const handleGenericDownloaderSuccess = useCallback( + (exportCount) => { + dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster] + ); + const tabs = useMemo( () => ( @@ -477,18 +504,7 @@ export const AllRules = React.memo( { - dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_RULES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} + onExportSuccess={handleGenericDownloaderSuccess} exportSelectedData={exportRules} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.test.ts new file mode 100644 index 00000000000000..6ccdaeacc7397b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.test.ts @@ -0,0 +1,295 @@ +/* + * 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 { FilterOptions, PaginationOptions } from '../../../../containers/detection_engine/rules'; + +import { Action, State, allRulesReducer } from './reducer'; +import { mockRule } from './__mocks__/mock'; + +const initialState: State = { + exportRuleIds: [], + filterOptions: { + filter: '', + sortField: 'enabled', + sortOrder: 'desc', + }, + loadingRuleIds: [], + loadingRulesAction: null, + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + rules: [], + selectedRuleIds: [], + lastUpdated: 0, + showIdleModal: false, + isRefreshPaused: true, + intervalValue: 0, +}; + +describe('allRulesReducer', () => { + let reducer: (state: State, action: Action) => State; + + beforeEach(() => { + jest.useFakeTimers(); + jest + .spyOn(global.Date, 'now') + .mockImplementationOnce(() => new Date('2020-10-31T11:01:58.135Z').valueOf()); + reducer = allRulesReducer({ current: undefined }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('#exportRuleIds', () => { + test('should update state with rules to be exported', () => { + const { loadingRuleIds, loadingRulesAction, exportRuleIds } = reducer(initialState, { + type: 'exportRuleIds', + ids: ['123', '456'], + }); + + expect(loadingRuleIds).toEqual(['123', '456']); + expect(exportRuleIds).toEqual(['123', '456']); + expect(loadingRulesAction).toEqual('export'); + }); + }); + + describe('#loadingRuleIds', () => { + test('should update state with rule ids with a pending action', () => { + const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: 'enable', + }); + + expect(loadingRuleIds).toEqual(['123', '456']); + expect(loadingRulesAction).toEqual('enable'); + }); + + test('should update loadingIds to empty array if action is null', () => { + const { loadingRuleIds, loadingRulesAction } = reducer(initialState, { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: null, + }); + + expect(loadingRuleIds).toEqual([]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should append rule ids to any existing loading ids', () => { + const { loadingRuleIds, loadingRulesAction } = reducer( + { ...initialState, loadingRuleIds: ['abc'] }, + { + type: 'loadingRuleIds', + ids: ['123', '456'], + actionType: 'duplicate', + } + ); + + expect(loadingRuleIds).toEqual(['abc', '123', '456']); + expect(loadingRulesAction).toEqual('duplicate'); + }); + }); + + describe('#selectedRuleIds', () => { + test('should update state with selected rule ids', () => { + const { selectedRuleIds } = reducer(initialState, { + type: 'selectedRuleIds', + ids: ['123', '456'], + }); + + expect(selectedRuleIds).toEqual(['123', '456']); + }); + }); + + describe('#setRules', () => { + test('should update rules and reset loading/selected rule ids', () => { + const { selectedRuleIds, loadingRuleIds, loadingRulesAction, pagination, rules } = reducer( + initialState, + { + type: 'setRules', + rules: [mockRule('someRuleId')], + pagination: { + page: 1, + perPage: 20, + total: 0, + }, + } + ); + + expect(rules).toEqual([mockRule('someRuleId')]); + expect(selectedRuleIds).toEqual([]); + expect(loadingRuleIds).toEqual([]); + expect(loadingRulesAction).toBeNull(); + expect(pagination).toEqual({ + page: 1, + perPage: 20, + total: 0, + }); + }); + }); + + describe('#updateRules', () => { + test('should return existing and new rules', () => { + const existingRule = { ...mockRule('123'), rule_id: 'rule-123' }; + const { rules, loadingRulesAction } = reducer( + { ...initialState, rules: [existingRule] }, + { + type: 'updateRules', + rules: [mockRule('someRuleId')], + } + ); + + expect(rules).toEqual([existingRule, mockRule('someRuleId')]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should return updated rule', () => { + const updatedRule = { ...mockRule('someRuleId'), description: 'updated rule' }; + const { rules, loadingRulesAction } = reducer( + { ...initialState, rules: [mockRule('someRuleId')] }, + { + type: 'updateRules', + rules: [updatedRule], + } + ); + + expect(rules).toEqual([updatedRule]); + expect(loadingRulesAction).toBeNull(); + }); + + test('should return updated existing loading rule ids', () => { + const existingRule = { ...mockRule('someRuleId'), id: '123', rule_id: 'rule-123' }; + const { loadingRuleIds, loadingRulesAction } = reducer( + { + ...initialState, + rules: [existingRule], + loadingRuleIds: ['123'], + loadingRulesAction: 'enable', + }, + { + type: 'updateRules', + rules: [mockRule('someRuleId')], + } + ); + + expect(loadingRuleIds).toEqual(['123']); + expect(loadingRulesAction).toEqual('enable'); + }); + }); + + describe('#updateFilterOptions', () => { + test('should return existing and new rules', () => { + const paginationMock: PaginationOptions = { + page: 1, + perPage: 20, + total: 0, + }; + const filterMock: FilterOptions = { + filter: 'host.name:*', + sortField: 'enabled', + sortOrder: 'desc', + }; + const { filterOptions, pagination } = reducer(initialState, { + type: 'updateFilterOptions', + filterOptions: filterMock, + pagination: paginationMock, + }); + + expect(filterOptions).toEqual(filterMock); + expect(pagination).toEqual(paginationMock); + }); + }); + + describe('#failure', () => { + test('should reset rules value to empty array', () => { + const { rules } = reducer(initialState, { + type: 'failure', + }); + + expect(rules).toEqual([]); + }); + }); + + describe('#setLastRefreshDate', () => { + test('should update last refresh date with current date', () => { + const { lastUpdated } = reducer(initialState, { + type: 'setLastRefreshDate', + }); + + expect(lastUpdated).toEqual(1604142118135); + }); + }); + + describe('#setShowIdleModal', () => { + test('should hide idle modal and restart refresh if "show" is false', () => { + const { showIdleModal, isRefreshPaused } = reducer(initialState, { + type: 'setShowIdleModal', + show: false, + }); + + expect(showIdleModal).toBeFalsy(); + expect(isRefreshPaused).toBeFalsy(); + }); + + test('should show idle modal and pause refresh if "show" is true', () => { + const { showIdleModal, isRefreshPaused } = reducer(initialState, { + type: 'setShowIdleModal', + show: true, + }); + + expect(showIdleModal).toBeTruthy(); + expect(isRefreshPaused).toBeTruthy(); + }); + }); + + describe('#setAutoRefreshPaused', () => { + test('should pause auto refresh if "paused" is true', () => { + const { isRefreshPaused } = reducer(initialState, { + type: 'setAutoRefreshPaused', + paused: true, + }); + + expect(isRefreshPaused).toBeTruthy(); + }); + + test('should resume auto refresh if "paused" is false', () => { + const { isRefreshPaused } = reducer(initialState, { + type: 'setAutoRefreshPaused', + paused: false, + }); + + expect(isRefreshPaused).toBeFalsy(); + }); + }); + + describe('#setAutoRefreshInterval', () => { + test('should pause auto refresh if interval is 0', () => { + const { isRefreshPaused, intervalValue } = reducer( + { ...initialState, isRefreshPaused: false }, + { + type: 'setAutoRefreshInterval', + interval: 0, + } + ); + + expect(isRefreshPaused).toBeTruthy(); + expect(intervalValue).toEqual(0); + }); + + test('should update auto refresh interval', () => { + const { isRefreshPaused, intervalValue } = reducer(initialState, { + type: 'setAutoRefreshInterval', + interval: 30000, + }); + + expect(isRefreshPaused).toEqual(initialState.isRefreshPaused); + expect(intervalValue).toEqual(30000); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts index 11c9eda9e2ed8e..f9cb28b75bd11d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts @@ -93,27 +93,24 @@ export const allRulesReducer = ( }; } case 'updateRules': { - if (state.rules != null) { - const ruleIds = state.rules.map((r) => r.id); - const updatedRules = action.rules.reduce((rules, updatedRule) => { - let newRules = rules; - if (ruleIds.includes(updatedRule.id)) { - newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); - } else { - newRules = [...newRules, updatedRule]; - } - return newRules; - }, state.rules); - const updatedRuleIds = action.rules.map((r) => r.id); - const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); - return { - ...state, - rules: updatedRules, - loadingRuleIds: newLoadingRuleIds, - loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, - }; - } - return state; + const ruleIds = state.rules.map((r) => r.id); + const updatedRules = action.rules.reduce((rules, updatedRule) => { + let newRules = rules; + if (ruleIds.includes(updatedRule.id)) { + newRules = newRules.map((r) => (updatedRule.id === r.id ? updatedRule : r)); + } else { + newRules = [...newRules, updatedRule]; + } + return newRules; + }, state.rules); + const updatedRuleIds = action.rules.map((r) => r.id); + const newLoadingRuleIds = state.loadingRuleIds.filter((id) => !updatedRuleIds.includes(id)); + return { + ...state, + rules: updatedRules, + loadingRuleIds: newLoadingRuleIds, + loadingRulesAction: newLoadingRuleIds.length === 0 ? null : state.loadingRulesAction, + }; } case 'updateFilterOptions': { return { @@ -157,6 +154,7 @@ export const allRulesReducer = ( return { ...state, intervalValue: action.interval, + isRefreshPaused: action.interval < 1 ? true : state.isRefreshPaused, }; } default: diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx index 92f69d79110d2b..af3aba42e6172e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -5,16 +5,57 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; +import { act } from '@testing-library/react'; import { RulesTableFilters } from './rules_table_filters'; describe('RulesTableFilters', () => { - it('renders correctly', () => { - const wrapper = shallow( - - ); + it('renders no numbers next to rule type button filter if none exist', async () => { + await act(async () => { + const wrapper = mount( + + ); - expect(wrapper.find('[data-test-subj="show-elastic-rules-filter-button"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules' + ); + }); + }); + + it('renders number of custom and prepackaged rules', async () => { + await act(async () => { + const wrapper = mount( + + ); + + expect(wrapper.find('[data-test-subj="showElasticRulesFilterButton"]').at(0).text()).toEqual( + 'Elastic rules (9)' + ); + expect(wrapper.find('[data-test-subj="showCustomRulesFilterButton"]').at(0).text()).toEqual( + 'Custom rules (10)' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index c5656e9888e1ae..b3775cdb8867eb 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -108,6 +108,7 @@ const RulesTableFiltersComponent = ({ onSelectedTagsChanged={handleSelectedTags} selectedTags={selectedTags} tags={tags} + data-test-subj="allRulesTagPopover" /> @@ -117,7 +118,7 @@ const RulesTableFiltersComponent = ({ {i18n.ELASTIC_RULES} @@ -126,7 +127,7 @@ const RulesTableFiltersComponent = ({ <> {i18n.CUSTOM_RULES} @@ -139,11 +140,13 @@ const RulesTableFiltersComponent = ({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index a8f4ea82d5ae3f..30ca1e9f3880e4 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -575,3 +575,31 @@ export const REFRESH_PROMPT_BODY = i18n.translate( defaultMessage: 'Rule auto-refresh has been paused. Click "Continue" to resume.', } ); + +export const REFRESH_RULE_POPOVER_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverTitle', + { + defaultMessage: 'Auto refresh', + } +); + +export const REFRESH_RULE_POPOVER_LEGEND = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverLegend', + { + defaultMessage: 'Refresh rules every', + } +); + +export const REFRESH_ON = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRuleOn', + { + defaultMessage: 'on', + } +); + +export const REFRESH_OFF = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRuleOff', + { + defaultMessage: 'off', + } +); From d3c983309b1f9fb6bd5ef6d3c095a085ea0b94a0 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 5 Nov 2020 17:24:08 -0500 Subject: [PATCH 6/9] updating per final concensus on feature --- .../security_solution/common/constants.ts | 6 + .../alerts_detection_rules.spec.ts | 25 +- .../cypress/screens/alerts_detection_rules.ts | 8 - .../cypress/tasks/alerts_detection_rules.ts | 10 - .../common/lib/kibana/kibana_react.mock.ts | 9 + .../detection_engine/rules/all/index.test.tsx | 242 ++++++------------ .../detection_engine/rules/all/index.tsx | 183 ++++++------- .../rules/all/reducer.test.ts | 54 +--- .../detection_engine/rules/all/reducer.ts | 19 +- .../rules_table_filters.test.tsx | 10 - .../rules_table_filters.tsx | 27 -- .../rules/all/utility_bar.test.tsx | 32 +++ .../rules/all/utility_bar.tsx | 40 ++- .../detection_engine/rules/translations.ts | 26 +- .../security_solution/server/ui_settings.ts | 29 +++ 15 files changed, 312 insertions(+), 408 deletions(-) diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 767a2616a4c7e3..8c423c663a4e8c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -39,6 +39,9 @@ export const FILTERS_GLOBAL_HEIGHT = 109; // px export const FULL_SCREEN_TOGGLED_CLASS_NAME = 'fullScreenToggled'; export const NO_ALERT_INDEX = 'no-alert-index-049FC71A-4C2C-446F-9901-37XMC5024C51'; export const ENDPOINT_METADATA_INDEX = 'metrics-endpoint.metadata-*'; +export const DEFAULT_RULE_REFRESH_INTERVAL_ON = true; +export const DEFAULT_RULE_REFRESH_INTERVAL_VALUE = 60000; // ms +export const DEFAULT_RULE_REFRESH_IDLE_VALUE = 2700000; // ms export enum SecurityPageName { detections = 'detections', @@ -74,6 +77,9 @@ export const DEFAULT_INDEX_PATTERN = [ /** This Kibana Advanced Setting enables the `Security news` feed widget */ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed'; +/** This Kibana Advanced Setting sets the auto refresh interval for the detections all rules table */ +export const DEFAULT_RULES_TABLE_REFRESH_SETTING = 'securitySolution:rulesTableRefresh'; + /** This Kibana Advanced Setting specifies the URL of the News feed widget */ export const NEWS_FEED_URL_SETTING = 'securitySolution:newsFeedUrl'; diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index b4c108b658e28f..9b0fdd8528b04f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -24,13 +24,13 @@ import { checkAutoRefresh, dismissAllRulesIdleModal, resetAllRulesIdleModalTimeout, - setAllRulesAutoRefreshIntervalInSeconds, sortByActivatedRules, waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded, waitForRuleToBeActivated, } from '../tasks/alerts_detection_rules'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; +import { DEFAULT_RULE_REFRESH_INTERVAL_VALUE } from '../../common/constants'; import { DETECTIONS_URL } from '../urls/navigation'; @@ -44,7 +44,7 @@ describe('Alerts detection rules', () => { cy.clock().invoke('restore'); }); - it('Sorts by activated rules', () => { + xit('Sorts by activated rules', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); @@ -83,8 +83,8 @@ describe('Alerts detection rules', () => { }); }); - it('Displays idle modal if auto refresh is on', () => { - cy.clock(Date.now(), ['setTimeout']); + it('Auto refreshes rules', () => { + cy.clock(Date.now()); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); @@ -92,29 +92,24 @@ describe('Alerts detection rules', () => { goToManageAlertsDetectionRules(); waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); - // idle modal should not show if refresh interval is not set - checkAllRulesIdleModal('not.be.visible'); - - // idle modal should show if refresh interval is set - setAllRulesAutoRefreshIntervalInSeconds(30); - - // mock 30 seconds passing to make sure refresh + // mock 1 minute passing to make sure refresh // is conducted - checkAutoRefresh(30000, 'be.visible'); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); // mock 45 minutes passing to check that idle modal shows // and refreshing is paused checkAllRulesIdleModal('be.visible'); - checkAutoRefresh(30000, 'not.be.visible'); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'not.be.visible'); // clicking on modal to continue, should resume refreshing dismissAllRulesIdleModal(); - checkAutoRefresh(30000, 'be.visible'); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); // if mouse movement detected, idle modal should not // show after 45 min - cy.clock().invoke('restore'); resetAllRulesIdleModalTimeout(); cy.get(RULE_AUTO_REFRESH_IDLE_MODAL).should('not.exist'); + + cy.clock().invoke('restore'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 8d1e5742cd4ec9..625c7969f5f7a5 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -65,14 +65,6 @@ export const SORT_RULES_BTN = '[data-test-subj="tableHeaderSortButton"]'; export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]'; -export const ALL_RULES_PANEL = '[data-test-subj="allRulesPanel"]'; - -export const RULE_AUTO_REFRESH_BUTTON = - 'button[data-test-subj="superDatePickerToggleQuickMenuButton"]'; - -export const RULE_AUTO_REFRESH_INPUT = - 'input[data-test-subj="superDatePickerRefreshIntervalInput"]'; - export const RULE_AUTO_REFRESH_START_STOP_BUTTON = '[data-test-subj="superDatePickerToggleRefreshButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index aa4c02ab6ed577..d4602dcd16db80 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -25,9 +25,6 @@ import { EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, NEXT_BTN, - RULE_AUTO_REFRESH_BUTTON, - RULE_AUTO_REFRESH_INPUT, - ALL_RULES_PANEL, ASYNC_LOADING_PROGRESS, RULE_AUTO_REFRESH_IDLE_MODAL, RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE, @@ -128,12 +125,6 @@ export const waitForRulesToBeLoaded = () => { cy.get(ASYNC_LOADING_PROGRESS).should('not.exist'); }; -export const setAllRulesAutoRefreshIntervalInSeconds = (interval: number) => { - cy.get(RULE_AUTO_REFRESH_BUTTON).eq(1).should('exist').click({ force: true, multiple: true }); - cy.get(RULE_AUTO_REFRESH_INPUT).type('{selectall}').type(`${interval}{enter}`); - cy.get(ALL_RULES_PANEL).first().click({ force: true, multiple: true }); -}; - // when using, ensure you've called cy.clock prior in test export const checkAutoRefresh = (ms: number, condition: string) => { cy.get(ASYNC_LOADING_PROGRESS).should('not.be.visible'); @@ -155,7 +146,6 @@ export const checkAllRulesIdleModal = (condition: string) => { }; export const resetAllRulesIdleModalTimeout = () => { - cy.clock(Date.now(), ['setTimeout']); cy.tick(2000000); cy.window().trigger('mousemove', { force: true }); cy.tick(700000); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 06c152b94cfd82..38ae49ba3b19cf 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -27,6 +27,10 @@ import { DEFAULT_REFRESH_RATE_INTERVAL, DEFAULT_TIME_RANGE, DEFAULT_TO, + DEFAULT_RULES_TABLE_REFRESH_SETTING, + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, } from '../../../../common/constants'; import { StartServices } from '../../../types'; import { createSecuritySolutionStorageMock } from '../../mock/mock_local_storage'; @@ -48,6 +52,11 @@ const mockUiSettings: Record = { [DEFAULT_DATE_FORMAT_TZ]: 'UTC', [DEFAULT_DATE_FORMAT]: 'MMM D, YYYY @ HH:mm:ss.SSS', [DEFAULT_DARK_MODE]: false, + [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { + on: DEFAULT_RULE_REFRESH_INTERVAL_ON, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, }; export const createUseUiSettingMock = () => (key: string, defaultValue?: unknown): unknown => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 9137f926b93fa6..be42d7b3212fd5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -11,18 +11,16 @@ import { waitFor } from '@testing-library/react'; import '../../../../../common/mock/match_media'; import '../../../../../common/mock/formatted_relative'; import { AllRules } from './index'; -import { useKibana } from '../../../../../common/lib/kibana'; +import { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; import { useRules, useRulesStatuses } from '../../../../containers/detection_engine/rules'; +import { TestProviders } from '../../../../../common/mock'; +import { createUseUiSetting$Mock } from '../../../../../common/lib/kibana/kibana_react.mock'; import { - TestProviders, - mockGlobalState, - apolloClientObservable, - SUB_PLUGINS_REDUCER, - kibanaObservable, - createSecuritySolutionStorageMock, -} from '../../../../../common/mock'; -import { createStore, State } from '../../../../../common/store'; -import { APP_ID } from '../../../../../../common/constants'; + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, + DEFAULT_RULES_TABLE_REFRESH_SETTING, +} from '../../../../../../common/constants'; jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); @@ -40,22 +38,29 @@ jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../containers/detection_engine/rules'); const useKibanaMock = useKibana as jest.Mocked; +const mockUseUiSetting$ = useUiSetting$ as jest.Mock; describe('AllRules', () => { const mockRefetchRulesData = jest.fn(); - const state: State = mockGlobalState; - const { storage } = createSecuritySolutionStorageMock(); - const store = createStore( - state, - SUB_PLUGINS_REDUCER, - apolloClientObservable, - kibanaObservable, - storage - ); beforeEach(() => { jest.useFakeTimers(); + mockUseUiSetting$.mockImplementation((key, defaultValue) => { + const useUiSetting$Mock = createUseUiSetting$Mock(); + + return key === DEFAULT_RULES_TABLE_REFRESH_SETTING + ? [ + { + on: DEFAULT_RULE_REFRESH_INTERVAL_ON, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, + jest.fn(), + ] + : useUiSetting$Mock(key, defaultValue); + }); + (useRules as jest.Mock).mockReturnValue([ false, { @@ -156,158 +161,79 @@ describe('AllRules', () => { expect(wrapper.find('[title="All rules"]')).toHaveLength(1); }); - describe('rules tab', () => { - it('it pulls from storage to determine if an auto refresh interval is set', async () => { - useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ - false, - 1800000, - ]); - - mount( - - - - ); - - await waitFor(() => { - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(1800000); - expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); - }); - }); - - it('it pulls from storage and does not set an auto refresh interval if storage indicates refresh is paused', async () => { - useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ - true, - 1800000, - ]); + it('it pulls from uiSettings to determine default refresh values', async () => { + mount( + + + + ); - mount( - - - - ); + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); - await waitFor(() => { - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - jest.advanceTimersByTime(1800000); - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - }); + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); }); + }); - it('it updates storage with auto refresh selection on component unmount', async () => { - useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ - false, - 1800000, - ]); - - const wrapper = mount( - - - - ); - - await waitFor(() => { - wrapper - .find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button') - .first() - .simulate('click'); - - wrapper - .find('input[data-test-subj="superDatePickerRefreshIntervalInput"]') - .simulate('change', { target: { value: '2' } }); - - wrapper - .find('[data-test-subj="superDatePickerToggleRefreshButton"]') - .first() - .simulate('click'); - - wrapper.unmount(); - wrapper.mount(); + // refresh functionality largely tested in cypress tests + it('it pulls from storage and does not set an auto refresh interval if storage indicates refresh is paused', async () => { + mockUseUiSetting$.mockImplementation(() => [ + { + on: false, + value: DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + idleTimeout: DEFAULT_RULE_REFRESH_IDLE_VALUE, + }, + jest.fn(), + ]); - expect( - useKibanaMock().services.storage.get(`${APP_ID}.detections.allRules.timeRefresh`) - ).toEqual([false, 1800000]); - }); - }); + const wrapper = mount( + + + + ); - it('it stops auto refreshing when user hits "Stop"', async () => { - useKibanaMock().services.storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [ - false, - 1800000, - ]); + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); - const wrapper = mount( - - - - ); + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); - await waitFor(() => { - wrapper - .find('[data-test-subj="superDatePickerToggleQuickMenuButton"] button') - .first() - .simulate('click'); + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); - wrapper - .find('[data-test-subj="superDatePickerToggleRefreshButton"]') - .first() - .simulate('click'); + wrapper.find('[data-test-subj="refreshSettingsSwitch"]').first().simulate('click'); - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - jest.advanceTimersByTime(1800000); - expect(mockRefetchRulesData).not.toHaveBeenCalled(); - }); + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); }); + }); + describe('rules tab', () => { it('renders all rules tab by default', async () => { const wrapper = mount( - + { it('renders monitoring tab when monitoring tab clicked', async () => { const wrapper = mount( - + ( }) => { const [initLoading, setInitLoading] = useState(true); const tableRef = useRef(); + const { + services: { + application: { + capabilities: { actions }, + }, + }, + } = useKibana(); + const [defaultAutoRefreshSetting] = useUiSetting$<{ + on: boolean; + value: number; + idleTimeout: number; + }>(DEFAULT_RULES_TABLE_REFRESH_SETTING); const [ { exportRuleIds, @@ -144,11 +161,14 @@ export const AllRules = React.memo( selectedRuleIds, lastUpdated, showIdleModal, - isRefreshPaused, - intervalValue, + isRefreshOn, }, dispatch, - ] = useReducer(allRulesReducer(tableRef), { ...initialState, lastUpdated: Date.now() }); + ] = useReducer(allRulesReducer(tableRef), { + ...initialState, + lastUpdated: Date.now(), + isRefreshOn: defaultAutoRefreshSetting.on, + }); const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -156,15 +176,6 @@ export const AllRules = React.memo( const [allRulesTab, setAllRulesTab] = useState(AllRulesTabs.rules); const { formatUrl } = useFormatUrl(SecurityPageName.detections); - // Auto rules info refresh refs - const idleTimeoutId = useRef(null); - - const handleClearIdleTimeout = useCallback(() => { - if (idleTimeoutId.current != null) { - clearTimeout(idleTimeoutId.current); - } - }, []); - // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); @@ -183,23 +194,16 @@ export const AllRules = React.memo( }); }, []); - const setAutoRefreshPaused = useCallback((paused: boolean) => { - dispatch({ - type: 'setAutoRefreshPaused', - paused, - }); - }, []); - - const setAutoRefreshInterval = useCallback((interval: number) => { + const setLastRefreshDate = useCallback(() => { dispatch({ - type: 'setAutoRefreshInterval', - interval, + type: 'setLastRefreshDate', }); }, []); - const setLastRefreshDate = useCallback(() => { + const setAutoRefreshOn = useCallback((on: boolean) => { dispatch({ - type: 'setLastRefreshDate', + type: 'setAutoRefreshOn', + on, }); }, []); @@ -225,14 +229,6 @@ export const AllRules = React.memo( rulesNotInstalled, rulesNotUpdated ); - const { - services: { - application: { - capabilities: { actions }, - }, - storage, - }, - } = useKibana(); const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [ actions, @@ -371,83 +367,63 @@ export const AllRules = React.memo( return false; }, [loadingRuleIds, loadingRulesAction]); - const handleResetIdleTimer = useCallback((): void => { - handleClearIdleTimeout(); + const handleRefreshData = useCallback((): void => { + if (reFetchRulesData != null && !isLoadingAnActionOnRule) { + reFetchRulesData(true); + setLastRefreshDate(); + } + }, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]); - if (!isRefreshPaused) { - idleTimeoutId.current = setTimeout(() => { - setShowIdleModal(true); - }, 2700000); + const handleResetIdleTimer = useCallback((): void => { + if (isRefreshOn) { + setShowIdleModal(true); + setAutoRefreshOn(false); } - }, [setShowIdleModal, handleClearIdleTimeout, isRefreshPaused]); + }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); - const handleIdleModalContinue = useCallback((): void => { - setShowIdleModal(false); - }, [setShowIdleModal]); - - const handleRefreshData = useCallback( - (forceRefresh = false): void => { - if ( - reFetchRulesData != null && - !isLoadingAnActionOnRule && - (!isRefreshPaused || forceRefresh) - ) { - reFetchRulesData(true); - setLastRefreshDate(); - } - }, - [reFetchRulesData, isLoadingAnActionOnRule, isRefreshPaused, setLastRefreshDate] - ); + const debounceResetIdleTimer = useMemo(() => { + return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); + }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); - const setLocalStorage = useCallback( - (paused: boolean, newInterval: number) => { - storage.set(`${APP_ID}.detections.allRules.timeRefresh`, [paused, newInterval]); - }, - [storage] - ); - - const debounceSetLocalStorage = useMemo(() => debounce(500, setLocalStorage), [ - setLocalStorage, - ]); + useEffect(() => { + const interval = setInterval(() => { + if (isRefreshOn) { + handleRefreshData(); + } + }, defaultAutoRefreshSetting.value); - const handleRefreshDataInterval = useCallback( - ({ isPaused, refreshInterval: interval }: OnRefreshChangeProps) => { - setAutoRefreshPaused(isPaused); - setAutoRefreshInterval(interval); + return () => { + clearInterval(interval); + }; + }, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]); - // refresh data when refresh interval is activated - if (interval > 0) { - debounceSetLocalStorage(isPaused, interval); + const handleIdleModalContinue = useCallback((): void => { + setShowIdleModal(false); + handleRefreshData(); + setAutoRefreshOn(true); + }, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]); + + const handleAutoRefreshSwitch = useCallback( + (refreshOn: boolean) => { + if (refreshOn) { + handleRefreshData(); } + setAutoRefreshOn(refreshOn); }, - [debounceSetLocalStorage, setAutoRefreshInterval, setAutoRefreshPaused] + [setAutoRefreshOn, handleRefreshData] ); - // on initial render, want to check if user has any interval info - useEffect((): void => { - if (initLoading) { - const [isStoredRefreshPaused, storedRefreshInterval] = storage.get( - `${APP_ID}.detections.allRules.timeRefresh` - ) ?? [true, 0]; - setAutoRefreshPaused(isStoredRefreshPaused); - setAutoRefreshInterval(storedRefreshInterval); - } - }, [initLoading, setAutoRefreshInterval, setAutoRefreshPaused, storage]); - useEffect(() => { - handleResetIdleTimer(); - - const fetchSuggestions = debounce(500, handleResetIdleTimer); + debounceResetIdleTimer(); - window.addEventListener('mousemove', fetchSuggestions, { passive: true }); - window.addEventListener('keydown', fetchSuggestions); + window.addEventListener('mousemove', debounceResetIdleTimer, { passive: true }); + window.addEventListener('keydown', debounceResetIdleTimer); return () => { - handleClearIdleTimeout(); - window.removeEventListener('mousemove', fetchSuggestions); - window.removeEventListener('keydown', fetchSuggestions); + window.removeEventListener('mousemove', debounceResetIdleTimer); + window.removeEventListener('keydown', debounceResetIdleTimer); }; - }, [handleClearIdleTimeout, handleResetIdleTimer]); + }, [handleResetIdleTimer, debounceResetIdleTimer]); const shouldShowRulesTable = useMemo( (): boolean => showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading, @@ -529,7 +505,7 @@ export const AllRules = React.memo( growLeftSplit={false} title={i18n.ALL_RULES} subtitle={ - @@ -539,11 +515,6 @@ export const AllRules = React.memo( onFilterChanged={onFilterChangedCallback} rulesCustomInstalled={rulesCustomInstalled} rulesInstalled={rulesInstalled} - isRefreshPaused={isRefreshPaused} - refreshInterval={intervalValue} - isLoading={loading || isLoadingRules || isLoadingRulesStatuses} - onRefresh={handleRefreshData} - onIntervalChange={handleRefreshDataInterval} /> @@ -582,6 +553,8 @@ export const AllRules = React.memo( numberSelectedRules={selectedRuleIds.length} onGetBatchItemsPopoverContent={getBatchItemsPopoverContent} onRefresh={handleRefreshData} + isAutoRefreshOn={isRefreshOn} + onRefreshSwitch={handleAutoRefreshSwitch} /> { @@ -228,68 +227,43 @@ describe('allRulesReducer', () => { describe('#setShowIdleModal', () => { test('should hide idle modal and restart refresh if "show" is false', () => { - const { showIdleModal, isRefreshPaused } = reducer(initialState, { + const { showIdleModal, isRefreshOn } = reducer(initialState, { type: 'setShowIdleModal', show: false, }); expect(showIdleModal).toBeFalsy(); - expect(isRefreshPaused).toBeFalsy(); + expect(isRefreshOn).toBeTruthy(); }); test('should show idle modal and pause refresh if "show" is true', () => { - const { showIdleModal, isRefreshPaused } = reducer(initialState, { + const { showIdleModal, isRefreshOn } = reducer(initialState, { type: 'setShowIdleModal', show: true, }); expect(showIdleModal).toBeTruthy(); - expect(isRefreshPaused).toBeTruthy(); + expect(isRefreshOn).toBeFalsy(); }); }); - describe('#setAutoRefreshPaused', () => { + describe('#setAutoRefreshOn', () => { test('should pause auto refresh if "paused" is true', () => { - const { isRefreshPaused } = reducer(initialState, { - type: 'setAutoRefreshPaused', - paused: true, + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: true, }); - expect(isRefreshPaused).toBeTruthy(); + expect(isRefreshOn).toBeTruthy(); }); test('should resume auto refresh if "paused" is false', () => { - const { isRefreshPaused } = reducer(initialState, { - type: 'setAutoRefreshPaused', - paused: false, + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: false, }); - expect(isRefreshPaused).toBeFalsy(); - }); - }); - - describe('#setAutoRefreshInterval', () => { - test('should pause auto refresh if interval is 0', () => { - const { isRefreshPaused, intervalValue } = reducer( - { ...initialState, isRefreshPaused: false }, - { - type: 'setAutoRefreshInterval', - interval: 0, - } - ); - - expect(isRefreshPaused).toBeTruthy(); - expect(intervalValue).toEqual(0); - }); - - test('should update auto refresh interval', () => { - const { isRefreshPaused, intervalValue } = reducer(initialState, { - type: 'setAutoRefreshInterval', - interval: 30000, - }); - - expect(isRefreshPaused).toEqual(initialState.isRefreshPaused); - expect(intervalValue).toEqual(30000); + expect(isRefreshOn).toBeFalsy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts index f9cb28b75bd11d..d603e5791f5ce1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/reducer.ts @@ -22,8 +22,7 @@ export interface State { selectedRuleIds: string[]; lastUpdated: number; showIdleModal: boolean; - isRefreshPaused: boolean; - intervalValue: number; + isRefreshOn: boolean; } export type Action = @@ -40,8 +39,7 @@ export type Action = | { type: 'failure' } | { type: 'setLastRefreshDate' } | { type: 'setShowIdleModal'; show: boolean } - | { type: 'setAutoRefreshPaused'; paused: boolean } - | { type: 'setAutoRefreshInterval'; interval: number }; + | { type: 'setAutoRefreshOn'; on: boolean }; export const allRulesReducer = ( tableRef: React.MutableRefObject | undefined> @@ -141,20 +139,13 @@ export const allRulesReducer = ( return { ...state, showIdleModal: action.show, - isRefreshPaused: action.show, + isRefreshOn: !action.show, }; } - case 'setAutoRefreshPaused': { + case 'setAutoRefreshOn': { return { ...state, - isRefreshPaused: action.paused, - }; - } - case 'setAutoRefreshInterval': { - return { - ...state, - intervalValue: action.interval, - isRefreshPaused: action.interval < 1 ? true : state.isRefreshPaused, + isRefreshOn: action.on, }; } default: diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx index af3aba42e6172e..a8205c24dca65f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.test.tsx @@ -18,11 +18,6 @@ describe('RulesTableFilters', () => { onFilterChanged={jest.fn()} rulesCustomInstalled={null} rulesInstalled={null} - isLoading={false} - isRefreshPaused={true} - refreshInterval={0} - onRefresh={jest.fn()} - onIntervalChange={jest.fn()} /> ); @@ -42,11 +37,6 @@ describe('RulesTableFilters', () => { onFilterChanged={jest.fn()} rulesCustomInstalled={10} rulesInstalled={9} - isLoading={false} - isRefreshPaused={true} - refreshInterval={0} - onRefresh={jest.fn()} - onIntervalChange={jest.fn()} /> ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx index b3775cdb8867eb..0b83a8437cc1ac 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/rules_table_filters.tsx @@ -12,8 +12,6 @@ import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem, - EuiSuperDatePicker, - OnRefreshChangeProps, } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; @@ -27,11 +25,6 @@ interface RulesTableFiltersProps { onFilterChanged: (filterOptions: Partial) => void; rulesCustomInstalled: number | null; rulesInstalled: number | null; - isLoading: boolean; - isRefreshPaused: boolean; - refreshInterval: number; - onRefresh: () => void; - onIntervalChange: (arg: OnRefreshChangeProps) => void; } /** @@ -44,11 +37,6 @@ const RulesTableFiltersComponent = ({ onFilterChanged, rulesCustomInstalled, rulesInstalled, - isRefreshPaused, - refreshInterval, - isLoading, - onRefresh, - onIntervalChange, }: RulesTableFiltersProps) => { const [filter, setFilter] = useState(''); const [selectedTags, setSelectedTags] = useState([]); @@ -87,8 +75,6 @@ const RulesTableFiltersComponent = ({ [selectedTags] ); - const NOOP = useCallback(() => {}, []); - return ( @@ -136,19 +122,6 @@ const RulesTableFiltersComponent = ({ - - - ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx index df563bcd3f83b6..3d49295bde50a0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { ThemeProvider } from 'styled-components'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; +import { waitFor } from '@testing-library/react'; import { AllRulesUtilityBar } from './utility_bar'; @@ -21,6 +22,8 @@ describe('AllRules', () => { paginationTotal={4} numberSelectedRules={1} onGetBatchItemsPopoverContent={jest.fn()} + isAutoRefreshOn={true} + onRefreshSwitch={jest.fn()} /> ); @@ -40,6 +43,8 @@ describe('AllRules', () => { paginationTotal={4} numberSelectedRules={1} onGetBatchItemsPopoverContent={jest.fn()} + isAutoRefreshOn={true} + onRefreshSwitch={jest.fn()} /> ); @@ -56,6 +61,8 @@ describe('AllRules', () => { paginationTotal={4} numberSelectedRules={1} onGetBatchItemsPopoverContent={jest.fn()} + isAutoRefreshOn={true} + onRefreshSwitch={jest.fn()} /> ); @@ -73,6 +80,8 @@ describe('AllRules', () => { paginationTotal={4} numberSelectedRules={1} onGetBatchItemsPopoverContent={jest.fn()} + isAutoRefreshOn={true} + onRefreshSwitch={jest.fn()} /> ); @@ -81,4 +90,27 @@ describe('AllRules', () => { expect(mockRefresh).toHaveBeenCalled(); }); + + it('invokes onRefreshSwitch when auto refresh switch is clicked', async () => { + const mockSwitch = jest.fn(); + const wrapper = mount( + ({ eui: euiDarkVars, darkMode: true })}> + + + ); + + await waitFor(() => { + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); + wrapper.find('[data-test-subj="refreshSettingsSwitch"] button').first().simulate('click'); + expect(mockSwitch).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx index c6493bcd4caabc..3553dcc9b7c143 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiContextMenuPanel } from '@elastic/eui'; +import { EuiContextMenuPanel, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import React, { useCallback } from 'react'; import { @@ -20,8 +20,10 @@ interface AllRulesUtilityBarProps { userHasNoPermissions: boolean; numberSelectedRules: number; paginationTotal: number; + isAutoRefreshOn: boolean; onRefresh: (refreshRule: boolean) => void; onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[]; + onRefreshSwitch: (checked: boolean) => void; } export const AllRulesUtilityBar = React.memo( @@ -31,6 +33,8 @@ export const AllRulesUtilityBar = React.memo( paginationTotal, numberSelectedRules, onGetBatchItemsPopoverContent, + isAutoRefreshOn, + onRefreshSwitch, }) => { const handleGetBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( @@ -39,6 +43,32 @@ export const AllRulesUtilityBar = React.memo( [onGetBatchItemsPopoverContent] ); + const handleAutoRefreshSwitch = useCallback( + (closePopover: () => void) => (e: EuiSwitchEvent) => { + onRefreshSwitch(e.target.checked); + closePopover(); + }, + [onRefreshSwitch] + ); + + const handleGetRefreshSettingsPopoverContent = useCallback( + (closePopover: () => void) => ( + , + ]} + /> + ), + [isAutoRefreshOn, handleAutoRefreshSwitch] + ); + return ( @@ -70,6 +100,14 @@ export const AllRulesUtilityBar = React.memo( > {i18n.REFRESH} + + {i18n.REFRESH_RULE_POPOVER_LABEL} + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 30ca1e9f3880e4..38fb457185b67b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -576,30 +576,16 @@ export const REFRESH_PROMPT_BODY = i18n.translate( } ); -export const REFRESH_RULE_POPOVER_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverTitle', +export const REFRESH_RULE_POPOVER_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverDescription', { - defaultMessage: 'Auto refresh', + defaultMessage: 'Automatically refresh table', } ); -export const REFRESH_RULE_POPOVER_LEGEND = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverLegend', +export const REFRESH_RULE_POPOVER_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverLabel', { - defaultMessage: 'Refresh rules every', - } -); - -export const REFRESH_ON = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.refreshRuleOn', - { - defaultMessage: 'on', - } -); - -export const REFRESH_OFF = i18n.translate( - 'xpack.securitySolution.detectionEngine.rules.refreshRuleOff', - { - defaultMessage: 'off', + defaultMessage: 'Refresh settings', } ); diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 4b5261edcdfd0e..6b10a9909e19cf 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -23,6 +23,10 @@ import { NEWS_FEED_URL_SETTING_DEFAULT, IP_REPUTATION_LINKS_SETTING, IP_REPUTATION_LINKS_SETTING_DEFAULT, + DEFAULT_RULES_TABLE_REFRESH_SETTING, + DEFAULT_RULE_REFRESH_INTERVAL_ON, + DEFAULT_RULE_REFRESH_INTERVAL_VALUE, + DEFAULT_RULE_REFRESH_IDLE_VALUE, } from '../common/constants'; export const initUiSettings = (uiSettings: CoreSetup['uiSettings']) => { @@ -112,6 +116,31 @@ export const initUiSettings = (uiSettings: CoreSetup['uiSettings']) => { requiresPageReload: true, schema: schema.boolean(), }, + [DEFAULT_RULES_TABLE_REFRESH_SETTING]: { + name: i18n.translate('xpack.securitySolution.uiSettings.rulesTableRefresh', { + defaultMessage: 'Rules auto refresh', + }), + description: i18n.translate( + 'xpack.securitySolution.uiSettings.rulesTableRefreshDescription', + { + defaultMessage: + '

Enables auto refresh on the all rules and monitoring tables, in milliseconds

', + } + ), + type: 'json', + value: `{ + "on": ${DEFAULT_RULE_REFRESH_INTERVAL_ON}, + "value": ${DEFAULT_RULE_REFRESH_INTERVAL_VALUE}, + "idleTimeout": ${DEFAULT_RULE_REFRESH_IDLE_VALUE} +}`, + category: ['securitySolution'], + requiresPageReload: true, + schema: schema.object({ + idleTimeout: schema.number({ min: 300000 }), + value: schema.number({ min: 60000 }), + on: schema.boolean(), + }), + }, [NEWS_FEED_URL_SETTING]: { name: i18n.translate('xpack.securitySolution.uiSettings.newsFeedUrl', { defaultMessage: 'News feed URL', From 35d33b8658a6c3c459e18e93373d99799de90616 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 5 Nov 2020 17:25:44 -0500 Subject: [PATCH 7/9] unskip cypress test --- .../cypress/integration/alerts_detection_rules.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts index 9b0fdd8528b04f..6a62caecfaa675 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules.spec.ts @@ -44,7 +44,7 @@ describe('Alerts detection rules', () => { cy.clock().invoke('restore'); }); - xit('Sorts by activated rules', () => { + it('Sorts by activated rules', () => { loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); waitForAlertsPanelToBeLoaded(); waitForAlertsIndexToBeCreated(); From c86d2fd0ce1f8e0f68cc925b71248d6434b6bf34 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 5 Nov 2020 23:08:23 -0500 Subject: [PATCH 8/9] cleanup --- .../pages/detection_engine/rules/all/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx index e3b776ebaae489..663a4bb242c069 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.tsx @@ -18,7 +18,6 @@ import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; import { debounce } from 'lodash/fp'; -import styled from 'styled-components'; import { useRules, @@ -56,12 +55,6 @@ import { AllRulesUtilityBar } from './utility_bar'; import { LastUpdatedAt } from '../../../../../common/components/last_updated'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; -// Added min-width to fix some jitter that occurs -// between "Updating..." and "Updated 3 seconds ago" -const MyLastUpdatedAt = styled(LastUpdatedAt)` - min-width: 190px; -`; - const INITIAL_SORT_FIELD = 'enabled'; const initialState: State = { exportRuleIds: [], @@ -505,7 +498,7 @@ export const AllRules = React.memo( growLeftSplit={false} title={i18n.ALL_RULES} subtitle={ - From d31c7b1af6351b063cc421c09750088261f31962 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 5 Nov 2020 23:18:01 -0500 Subject: [PATCH 9/9] cleanup --- .../cypress/screens/alerts_detection_rules.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts index 625c7969f5f7a5..5ac8cd8f6cc9f7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts_detection_rules.ts @@ -65,9 +65,6 @@ export const SORT_RULES_BTN = '[data-test-subj="tableHeaderSortButton"]'; export const THREE_HUNDRED_ROWS = '[data-test-subj="tablePagination-300-rows"]'; -export const RULE_AUTO_REFRESH_START_STOP_BUTTON = - '[data-test-subj="superDatePickerToggleRefreshButton"]'; - export const RULE_AUTO_REFRESH_IDLE_MODAL = '[data-test-subj="allRulesIdleModal"]'; export const RULE_AUTO_REFRESH_IDLE_MODAL_CONTINUE = '[data-test-subj="allRulesIdleModal"] button';