From 88f7ca085cd943b91d7bf5f28c9d1464d31e273d Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Fri, 6 Nov 2020 19:35:33 -0500 Subject: [PATCH] [Security Solution][Detections] - Auto refresh all rules/monitoring tables (#82062) (#82890) ## Summary This PR addresses https://github.com/elastic/kibana/issues/63865 . Please read the issue for more detail, but essentially, stale data on the tables and use of relative date format leads to confusion as to whether the table was auto refreshing or not. --- .../security_solution/common/constants.ts | 6 + .../alerts_detection_rules.spec.ts | 37 +++ .../cypress/screens/alerts_detection_rules.ts | 10 +- .../cypress/tasks/alerts_detection_rules.ts | 38 ++- .../__snapshots__/index.test.tsx.snap | 4 +- .../components/header_section/index.tsx | 4 +- .../components/last_updated/index.test.tsx | 47 +++ .../common/components/last_updated/index.tsx | 83 +++++ .../components/last_updated/translations.ts | 15 + .../common/lib/kibana/kibana_react.mock.ts | 9 + .../detection_engine/rules/all/columns.tsx | 2 +- .../detection_engine/rules/all/index.test.tsx | 196 +++++++----- .../detection_engine/rules/all/index.tsx | 301 ++++++++++++------ .../rules/all/reducer.test.ts | 269 ++++++++++++++++ .../detection_engine/rules/all/reducer.ts | 66 ++-- .../rules_table_filters.test.tsx | 43 ++- .../rules_table_filters.tsx | 8 +- .../rules/all/utility_bar.test.tsx | 116 +++++++ .../rules/all/utility_bar.tsx | 118 +++++++ .../detection_engine/rules/translations.ts | 35 ++ .../components/timeline/footer/index.tsx | 2 +- .../timeline/footer/last_updated.tsx | 68 ---- .../timeline/footer/translations.ts | 4 - .../security_solution/server/ui_settings.ts | 29 ++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 26 files changed, 1226 insertions(+), 286 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/reducer.test.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/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 767a2616a4c7e..8c423c663a4e8 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 3fa304ab7cf19..6a62caecfaa67 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,12 +20,17 @@ import { } from '../tasks/alerts'; import { activateRule, + checkAllRulesIdleModal, + checkAutoRefresh, + dismissAllRulesIdleModal, + resetAllRulesIdleModalTimeout, 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'; @@ -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,34 @@ describe('Alerts detection rules', () => { }); }); }); + + it('Auto refreshes rules', () => { + cy.clock(Date.now()); + + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + + // mock 1 minute passing to make sure refresh + // is conducted + 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(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'not.be.visible'); + + // clicking on modal to continue, should resume refreshing + dismissAllRulesIdleModal(); + checkAutoRefresh(DEFAULT_RULE_REFRESH_INTERVAL_VALUE, 'be.visible'); + + // if mouse movement detected, idle modal should not + // show after 45 min + 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 0d0ea8460edf1..5ac8cd8f6cc9f 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,7 @@ 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 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 1c430e12b6b73..d4602dcd16db8 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,9 @@ import { EXPORT_ACTION_BTN, EDIT_RULE_ACTION_BTN, NEXT_BTN, + 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 +68,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 +121,32 @@ 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'); +}; + +// 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.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 f2d2d23d60fb1..d3d20c7183570 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`] = ` - + = ({ @@ -57,10 +58,11 @@ const HeaderSectionComponent: React.FC = ({ title, titleSize = 'm', tooltip, + growLeftSplit = true, }) => (
- + 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 0000000000000..db42794448c53 --- /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 0000000000000..ef4ff0123dd1c --- /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 0000000000000..77278563b24d5 --- /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/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 06c152b94cfd8..38ae49ba3b19c 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/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 6800743db738e..2b03d6dd4de36 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 1a4c2d405dca3..be42d7b3212fd 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,21 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; +import { waitFor } 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 { 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 { + 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'); @@ -27,66 +35,33 @@ 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; +const mockUseUiSetting$ = useUiSetting$ as jest.Mock; -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(); -jest.mock('../../../../containers/detection_engine/rules', () => { - return { - useRules: jest.fn().mockReturnValue([ + 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, { page: 1, @@ -126,8 +101,10 @@ jest.mock('../../../../containers/detection_engine/rules', () => { }, ], }, - ]), - useRulesStatuses: jest.fn().mockReturnValue({ + mockRefetchRulesData, + ]); + + (useRulesStatuses as jest.Mock).mockReturnValue({ loading: false, rulesStatuses: [ { @@ -150,21 +127,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 +136,12 @@ describe('AllRules', () => { actions: { show: true }, }; }); + + afterEach(() => { + jest.clearAllTimers(); + jest.clearAllMocks(); + }); + it('renders correctly', () => { const wrapper = shallow( { expect(wrapper.find('[title="All rules"]')).toHaveLength(1); }); + it('it pulls from uiSettings to determine default refresh values', async () => { + mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).toHaveBeenCalledTimes(1); + }); + }); + + // 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(), + ]); + + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + + wrapper.find('[data-test-subj="refreshSettings"] button').first().simulate('click'); + + wrapper.find('[data-test-subj="refreshSettingsSwitch"]').first().simulate('click'); + + jest.advanceTimersByTime(DEFAULT_RULE_REFRESH_INTERVAL_VALUE); + expect(mockRefetchRulesData).not.toHaveBeenCalled(); + }); + }); + describe('rules tab', () => { - it('renders correctly', async () => { + it('renders all rules tab by default', async () => { const wrapper = mount( { /> ); - 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 86b3daddd6c19..663a4bb242c06 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,15 +6,18 @@ import { EuiBasicTable, - EuiContextMenuPanel, EuiLoadingContent, EuiSpacer, EuiTab, EuiTabs, + EuiProgress, + EuiOverlayMask, + EuiConfirmModal, } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import uuid from 'uuid'; +import { debounce } from 'lodash/fp'; import { useRules, @@ -27,14 +30,7 @@ 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 { useKibana, useUiSetting$ } from '../../../../../common/lib/kibana'; import { useStateToaster } from '../../../../../common/components/toasters'; import { Loader } from '../../../../../common/components/loader'; import { Panel } from '../../../../../common/components/panel'; @@ -55,6 +51,9 @@ 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'; +import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; const INITIAL_SORT_FIELD = 'enabled'; const initialState: State = { @@ -73,6 +72,9 @@ const initialState: State = { }, rules: [], selectedRuleIds: [], + lastUpdated: 0, + showIdleModal: false, + isRefreshOn: true, }; interface AllRulesProps { @@ -129,6 +131,18 @@ export const AllRules = React.memo( }) => { 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, @@ -138,9 +152,16 @@ export const AllRules = React.memo( pagination, rules, selectedRuleIds, + lastUpdated, + showIdleModal, + isRefreshOn, }, dispatch, - ] = useReducer(allRulesReducer(tableRef), initialState); + ] = useReducer(allRulesReducer(tableRef), { + ...initialState, + lastUpdated: Date.now(), + isRefreshOn: defaultAutoRefreshSetting.on, + }); const { loading: isLoadingRulesStatuses, rulesStatuses } = useRulesStatuses(rules); const history = useHistory(); const [, dispatchToaster] = useStateToaster(); @@ -159,6 +180,26 @@ export const AllRules = React.memo( }); }, []); + const setShowIdleModal = useCallback((show: boolean) => { + dispatch({ + type: 'setShowIdleModal', + show, + }); + }, []); + + const setLastRefreshDate = useCallback(() => { + dispatch({ + type: 'setLastRefreshDate', + }); + }, []); + + const setAutoRefreshOn = useCallback((on: boolean) => { + dispatch({ + type: 'setAutoRefreshOn', + on, + }); + }, []); + const [isLoadingRules, , reFetchRulesData] = useRules({ pagination, filterOptions, @@ -181,34 +222,25 @@ export const AllRules = React.memo( rulesNotInstalled, rulesNotUpdated ); - const { - services: { - application: { - capabilities: { actions }, - }, - }, - } = useKibana(); const hasActionsPrivileges = useMemo(() => (isBoolean(actions.show) ? actions.show : true), [ actions, ]); const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - ), + (closePopover: () => void): JSX.Element[] => { + return getBatchItems({ + closePopover, + dispatch, + dispatchToaster, + hasMlPermissions, + hasActionsPrivileges, + loadingRuleIds, + selectedRuleIds, + reFetchRules: reFetchRulesData, + rules, + }); + }, [ dispatch, dispatchToaster, @@ -328,6 +360,94 @@ export const AllRules = React.memo( return false; }, [loadingRuleIds, loadingRulesAction]); + const handleRefreshData = useCallback((): void => { + if (reFetchRulesData != null && !isLoadingAnActionOnRule) { + reFetchRulesData(true); + setLastRefreshDate(); + } + }, [reFetchRulesData, isLoadingAnActionOnRule, setLastRefreshDate]); + + const handleResetIdleTimer = useCallback((): void => { + if (isRefreshOn) { + setShowIdleModal(true); + setAutoRefreshOn(false); + } + }, [setShowIdleModal, setAutoRefreshOn, isRefreshOn]); + + const debounceResetIdleTimer = useMemo(() => { + return debounce(defaultAutoRefreshSetting.idleTimeout, handleResetIdleTimer); + }, [handleResetIdleTimer, defaultAutoRefreshSetting.idleTimeout]); + + useEffect(() => { + const interval = setInterval(() => { + if (isRefreshOn) { + handleRefreshData(); + } + }, defaultAutoRefreshSetting.value); + + return () => { + clearInterval(interval); + }; + }, [isRefreshOn, handleRefreshData, defaultAutoRefreshSetting.value]); + + const handleIdleModalContinue = useCallback((): void => { + setShowIdleModal(false); + handleRefreshData(); + setAutoRefreshOn(true); + }, [setShowIdleModal, setAutoRefreshOn, handleRefreshData]); + + const handleAutoRefreshSwitch = useCallback( + (refreshOn: boolean) => { + if (refreshOn) { + handleRefreshData(); + } + setAutoRefreshOn(refreshOn); + }, + [setAutoRefreshOn, handleRefreshData] + ); + + useEffect(() => { + debounceResetIdleTimer(); + + window.addEventListener('mousemove', debounceResetIdleTimer, { passive: true }); + window.addEventListener('keydown', debounceResetIdleTimer); + + return () => { + window.removeEventListener('mousemove', debounceResetIdleTimer); + window.removeEventListener('keydown', debounceResetIdleTimer); + }; + }, [handleResetIdleTimer, debounceResetIdleTimer]); + + 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 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( () => ( @@ -353,27 +473,37 @@ 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} /> {tabs} - + <> - + {(isLoadingRules || isLoadingRulesStatuses) && ( + + )} + + } + > ( /> - {(loading || isLoadingRules || isLoadingAnActionOnRule || isLoadingRulesStatuses) && - !initLoading && ( - - )} - {rulesCustomInstalled != null && - rulesCustomInstalled === 0 && - prePackagedRuleStatus === 'ruleNotInstalled' && - !initLoading && ( - - )} + {isLoadingAnActionOnRule && !initLoading && ( + + )} + {shouldShowPrepackagedRulesPrompt && ( + + )} {initLoading && ( )} - {showRulesTable({ rulesCustomInstalled, rulesInstalled }) && !initLoading && ( + {showIdleModal && ( + + +

{i18n.REFRESH_PROMPT_BODY}

+
+
+ )} + {shouldShowRulesTable && ( <> - - - - - {i18n.SHOWING_RULES(pagination.total ?? 0)} - - - - - {i18n.SELECTED_RULES(selectedRuleIds.length)} - {!hasNoPermissions && ( - - {i18n.BATCH_ACTIONS} - - )} - reFetchRulesData(true)} - > - {i18n.REFRESH} - - - - + { + 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, isRefreshOn } = reducer(initialState, { + type: 'setShowIdleModal', + show: false, + }); + + expect(showIdleModal).toBeFalsy(); + expect(isRefreshOn).toBeTruthy(); + }); + + test('should show idle modal and pause refresh if "show" is true', () => { + const { showIdleModal, isRefreshOn } = reducer(initialState, { + type: 'setShowIdleModal', + show: true, + }); + + expect(showIdleModal).toBeTruthy(); + expect(isRefreshOn).toBeFalsy(); + }); + }); + + describe('#setAutoRefreshOn', () => { + test('should pause auto refresh if "paused" is true', () => { + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: true, + }); + + expect(isRefreshOn).toBeTruthy(); + }); + + test('should resume auto refresh if "paused" is false', () => { + const { isRefreshOn } = reducer(initialState, { + type: 'setAutoRefreshOn', + on: false, + }); + + 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 ff9b41bed06f5..d603e5791f5ce 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 @@ -20,6 +20,9 @@ export interface State { pagination: PaginationOptions; rules: Rule[]; selectedRuleIds: string[]; + lastUpdated: number; + showIdleModal: boolean; + isRefreshOn: boolean; } export type Action = @@ -33,7 +36,10 @@ export type Action = filterOptions: Partial; pagination: Partial; } - | { type: 'failure' }; + | { type: 'failure' } + | { type: 'setLastRefreshDate' } + | { type: 'setShowIdleModal'; show: boolean } + | { type: 'setAutoRefreshOn'; on: boolean }; export const allRulesReducer = ( tableRef: React.MutableRefObject | undefined> @@ -85,27 +91,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 { @@ -126,6 +129,25 @@ export const allRulesReducer = ( rules: [], }; } + case 'setLastRefreshDate': { + return { + ...state, + lastUpdated: Date.now(), + }; + } + case 'setShowIdleModal': { + return { + ...state, + showIdleModal: action.show, + isRefreshOn: !action.show, + }; + } + case 'setAutoRefreshOn': { + return { + ...state, + isRefreshOn: action.on, + }; + } default: return state; } 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 92f69d79110d2..a8205c24dca65 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,47 @@ */ 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 0f201fcbaa441..0b83a8437cc1a 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 @@ -14,6 +14,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import { isEqual } from 'lodash/fp'; + import * as i18n from '../../translations'; import { FilterOptions } from '../../../../../containers/detection_engine/rules'; @@ -76,7 +77,7 @@ const RulesTableFiltersComponent = ({ return ( - + @@ -102,7 +104,7 @@ const RulesTableFiltersComponent = ({ {i18n.ELASTIC_RULES} @@ -111,7 +113,7 @@ const RulesTableFiltersComponent = ({ <> {i18n.CUSTOM_RULES} 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 new file mode 100644 index 0000000000000..3d49295bde50a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 { 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'; + +describe('AllRules', () => { + 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(); + }); + + 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 new file mode 100644 index 0000000000000..3553dcc9b7c14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/utility_bar.tsx @@ -0,0 +1,118 @@ +/* + * 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, EuiSwitch, EuiSwitchEvent } 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; + isAutoRefreshOn: boolean; + onRefresh: (refreshRule: boolean) => void; + onGetBatchItemsPopoverContent: (closePopover: () => void) => JSX.Element[]; + onRefreshSwitch: (checked: boolean) => void; +} + +export const AllRulesUtilityBar = React.memo( + ({ + userHasNoPermissions, + onRefresh, + paginationTotal, + numberSelectedRules, + onGetBatchItemsPopoverContent, + isAutoRefreshOn, + onRefreshSwitch, + }) => { + const handleGetBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + + ), + [onGetBatchItemsPopoverContent] + ); + + const handleAutoRefreshSwitch = useCallback( + (closePopover: () => void) => (e: EuiSwitchEvent) => { + onRefreshSwitch(e.target.checked); + closePopover(); + }, + [onRefreshSwitch] + ); + + const handleGetRefreshSettingsPopoverContent = useCallback( + (closePopover: () => void) => ( + , + ]} + /> + ), + [isAutoRefreshOn, handleAutoRefreshSwitch] + ); + + return ( + + + + + {i18n.SHOWING_RULES(paginationTotal)} + + + + + + {i18n.SELECTED_RULES(numberSelectedRules)} + + {!userHasNoPermissions && ( + + {i18n.BATCH_ACTIONS} + + )} + + {i18n.REFRESH} + + + {i18n.REFRESH_RULE_POPOVER_LABEL} + + + + + ); + } +); + +AllRulesUtilityBar.displayName = 'AllRulesUtilityBar'; 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 d20b97a98fbf5..38fb457185b67 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,38 @@ 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.', + } +); + +export const REFRESH_RULE_POPOVER_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverDescription', + { + defaultMessage: 'Automatically refresh table', + } +); + +export const REFRESH_RULE_POPOVER_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.refreshRulePopoverLabel', + { + defaultMessage: 'Refresh settings', + } +); 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 4119127d5a108..f56d7d90cf2df 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 06ece50690c09..0000000000000 --- 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 f581d0757bc3c..016406d6bd061 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', { diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 4b5261edcdfd0..6b10a9909e19c 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', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 11993da7568c2..8d45511e04338 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17840,7 +17840,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 c188603c98556..80e9bc51585e2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17859,7 +17859,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": "一毫秒",