From 0c0aaf0e6a0b5ad18902b6573664270b59ede10f Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Wed, 15 Jul 2020 04:12:34 -0600 Subject: [PATCH 01/52] [Security Solution] Full screen timeline, Collapse event (#71786) ## Full screen Timeline & Timeline-based views - Adds a _Full screen_ mode to Timeline, and all Timeline-based views, including: - Detections - Detections > Rule details - Hosts > Events - Hosts > External alerts - Network > External alerts - Timeline - Enter full screen from any Resolver - Adds a `Collapse event` action for quickly collapsing an expanded Timeline event - Hides the `Add to case action` in timeline-based Resolver views, so those actions are only enabled in Timeline (a `TODO` from https://github.com/elastic/kibana/pull/70111) ### Full screen detections ![full-screen-detections](https://user-images.githubusercontent.com/4459398/87493332-d348f280-c609-11ea-9399-126d2259daa2.gif) ### Enter full screen from any Resolver ![full-screen-resolver](https://user-images.githubusercontent.com/4459398/87493348-de038780-c609-11ea-86a3-52ab24055e38.gif) ### Full screen Timeline ![full-screen-timeline](https://user-images.githubusercontent.com/4459398/87493394-f4114800-c609-11ea-8d62-4add291d937a.gif) ### Collapse event ![collapse-event](https://user-images.githubusercontent.com/4459398/87493408-fa9fbf80-c609-11ea-88c8-fa87d82d1eb1.gif) ### Sort tooltip ![sort-tooltip](https://user-images.githubusercontent.com/4459398/87493417-012e3700-c60a-11ea-9905-44e3b7cfe60f.gif) --- .../security_solution/common/constants.ts | 2 + .../public/app/home/index.tsx | 2 +- .../components/all_cases/columns.test.tsx | 1 + .../cases/components/all_cases/index.test.tsx | 2 + .../components/all_cases_modal/index.test.tsx | 1 + .../cases/components/case_view/index.test.tsx | 1 + .../configure_cases/button.test.tsx | 1 + .../use_push_to_service/index.test.tsx | 2 + .../components/alerts_viewer/alerts_table.tsx | 3 + .../common/components/alerts_viewer/index.tsx | 47 +- .../components/autocomplete/helpers.test.ts | 1 + .../components/charts/barchart.test.tsx | 1 + .../charts/draggable_legend.test.tsx | 1 + .../charts/draggable_legend_item.test.tsx | 1 + .../drag_and_drop/draggable_wrapper.test.tsx | 1 + .../draggable_wrapper_hover_content.test.tsx | 1 + .../components/draggables/index.test.tsx | 1 + .../__snapshots__/event_details.test.tsx.snap | 27 + .../event_details/event_details.test.tsx | 30 + .../event_details/event_details.tsx | 54 +- .../event_fields_browser.test.tsx | 1 + .../event_details/stateful_event_details.tsx | 13 +- .../events_viewer/events_viewer.test.tsx | 1 + .../events_viewer/events_viewer.tsx | 64 +- .../components/events_viewer/index.test.tsx | 1 + .../common/components/events_viewer/index.tsx | 5 + .../components/exit_full_screen/index.tsx | 49 + .../exit_full_screen/translations.ts | 11 + .../filters_global/filters_global.tsx | 2 + .../common/components/header_global/index.tsx | 9 +- .../header_page/editable_title.test.tsx | 1 + .../components/header_page/index.test.tsx | 1 + .../components/header_page/title.test.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 4 +- .../components/header_section/index.tsx | 12 +- .../components/ml/entity_draggable.test.tsx | 2 + .../ml/score/anomaly_score.test.tsx | 2 + .../ml/score/anomaly_scores.test.tsx | 2 + .../ml/score/draggable_score.test.tsx | 4 +- .../get_anomalies_host_table_columns.test.tsx | 4 +- ...t_anomalies_network_table_columns.test.tsx | 1 + .../public/common/components/page/index.tsx | 8 +- .../common/components/tables/helpers.test.tsx | 6 +- .../common/components/top_n/index.test.tsx | 1 + .../common/components/top_n/top_n.test.tsx | 1 + .../common/components/wrapper_page/index.tsx | 8 +- .../containers/use_full_screen/index.tsx | 39 + .../public/common/store/inputs/actions.ts | 5 + .../public/common/store/inputs/helpers.ts | 16 + .../public/common/store/inputs/model.ts | 1 + .../public/common/store/inputs/reducer.ts | 9 + .../public/common/store/inputs/selectors.ts | 7 + .../alerts_histogram.test.tsx | 1 + .../alerts_histogram_panel/index.test.tsx | 1 + .../components/alerts_table/index.test.tsx | 1 + .../components/alerts_table/index.tsx | 3 + .../index.test.tsx | 1 + .../rules/all_rules_tables/index.test.tsx | 1 + .../load_empty_prompt.test.tsx | 1 + .../detection_engine.test.tsx | 51 +- .../detection_engine/detection_engine.tsx | 103 +- .../rules/all/columns.test.tsx | 1 + .../detection_engine/rules/all/index.test.tsx | 1 + .../rules/create/index.test.tsx | 1 + .../rules/details/index.test.tsx | 51 +- .../detection_engine/rules/details/index.tsx | 261 ++-- .../rules/edit/index.test.tsx | 1 + .../detection_engine/rules/index.test.tsx | 1 + .../authentications_table/index.test.tsx | 1 + .../components/hosts_table/index.test.tsx | 1 + .../hosts/components/kpi_hosts/index.test.tsx | 1 + .../uncommon_process_table/index.test.tsx | 1 + .../hosts/pages/details/details_tabs.test.tsx | 1 + .../public/hosts/pages/display.tsx | 13 + .../public/hosts/pages/hosts.tsx | 77 +- .../navigation/events_query_tab_body.tsx | 49 +- .../components/direction/direction.test.tsx | 1 + .../embeddables/embedded_map.test.tsx | 1 + .../line_tool_tip_content.test.tsx | 2 + .../map_tool_tip/map_tool_tip.test.tsx | 1 + .../point_tool_tip_content.test.tsx | 2 + .../index.test.tsx | 1 + .../network/components/ip/index.test.tsx | 1 + .../components/ip_overview/index.test.tsx | 1 + .../components/kpi_network/index.test.tsx | 1 + .../network_dns_table/index.test.tsx | 1 + .../network_http_table/index.test.tsx | 1 + .../index.test.tsx | 1 + .../network_top_n_flow_table/index.test.tsx | 1 + .../network/components/port/index.test.tsx | 1 + .../source_destination/index.test.tsx | 1 + .../source_destination_ip.test.tsx | 1 + .../components/tls_table/index.test.tsx | 1 + .../components/users_table/index.test.tsx | 1 + .../public/network/pages/network.tsx | 95 +- .../alerts_by_category/index.test.tsx | 1 + .../components/event_counts/index.test.tsx | 1 + .../endpoint_overview/index.test.tsx | 2 + .../components/host_overview/index.test.tsx | 1 + .../components/overview_host/index.test.tsx | 1 + .../overview_network/index.test.tsx | 1 + .../certificate_fingerprint/index.test.tsx | 1 + .../components/duration/index.test.tsx | 1 + .../field_renderers/field_renderers.test.tsx | 1 + .../fields_browser/category.test.tsx | 1 + .../fields_browser/field_browser.test.tsx | 1 + .../fields_browser/field_items.test.tsx | 1 + .../fields_browser/field_name.test.tsx | 1 + .../fields_browser/fields_pane.test.tsx | 1 + .../components/fields_browser/index.test.tsx | 1 + .../header_with_close_button/index.test.tsx | 1 + .../components/flyout/pane/index.tsx | 16 +- .../flyout/pane/timeline_resize_handle.tsx | 14 +- .../components/graph_overlay/index.tsx | 99 +- .../components/ja3_fingerprint/index.test.tsx | 1 + .../components/netflow/index.test.tsx | 1 + .../components/open_timeline/index.test.tsx | 1 + .../open_timeline/open_timeline.test.tsx | 1 + .../open_timeline_modal_body.test.tsx | 1 + .../timelines_table/actions_columns.test.tsx | 1 + .../timelines_table/common_columns.test.tsx | 1 + .../timelines_table/extended_columns.test.tsx | 1 + .../icon_header_columns.test.tsx | 1 + .../timelines_table/index.test.tsx | 1 + .../__snapshots__/index.test.tsx.snap | 1046 +++++++++-------- .../body/column_headers/helpers.test.ts | 7 +- .../body/column_headers/index.test.tsx | 35 +- .../timeline/body/column_headers/index.tsx | 64 +- .../body/column_headers/translations.ts | 4 + .../components/timeline/body/constants.ts | 4 +- .../body/data_driven_columns/index.test.tsx | 1 + .../timeline/body/events/stateful_event.tsx | 1 + .../components/timeline/body/helpers.ts | 35 + .../components/timeline/body/index.test.tsx | 1 + .../components/timeline/body/index.tsx | 48 +- .../timeline/body/renderers/args.test.tsx | 1 + .../renderers/auditd/generic_details.test.tsx | 1 + .../auditd/generic_file_details.test.tsx | 1 + .../primary_secondary_user_info.test.tsx | 1 + .../session_user_host_working_dir.test.tsx | 1 + .../body/renderers/bytes/index.test.tsx | 1 + .../dns/dns_request_event_details.test.tsx | 1 + .../dns_request_event_details_line.test.tsx | 2 +- .../renderers/empty_column_renderer.test.tsx | 1 + .../endgame_security_event_details.test.tsx | 1 + ...dgame_security_event_details_line.test.tsx | 1 + .../renderers/exit_code_draggable.test.tsx | 1 + .../body/renderers/file_draggable.test.tsx | 1 + .../body/renderers/formatted_field.test.tsx | 1 + .../renderers/get_column_renderer.test.tsx | 1 + .../body/renderers/get_row_renderer.test.tsx | 1 + .../body/renderers/host_working_dir.test.tsx | 1 + .../netflow/netflow_row_renderer.test.tsx | 1 + .../parent_process_draggable.test.tsx | 1 + .../renderers/plain_column_renderer.test.tsx | 1 + .../body/renderers/process_draggable.test.tsx | 1 + .../body/renderers/process_hash.test.tsx | 1 + .../suricata/suricata_details.test.tsx | 1 + .../suricata/suricata_row_renderer.test.tsx | 1 + .../suricata/suricata_signature.test.tsx | 1 + .../body/renderers/system/auth_ssh.test.tsx | 1 + .../renderers/system/generic_details.test.tsx | 1 + .../system/generic_file_details.test.tsx | 1 + .../body/renderers/system/package.test.tsx | 1 + .../renderers/user_host_working_dir.test.tsx | 1 + .../body/renderers/zeek/zeek_details.test.tsx | 1 + .../renderers/zeek/zeek_row_renderer.test.tsx | 1 + .../renderers/zeek/zeek_signature.test.tsx | 1 + .../sort_indicator.test.tsx.snap | 15 +- .../body/sort/sort_indicator.test.tsx | 43 +- .../timeline/body/sort/sort_indicator.tsx | 26 +- .../components/timeline/body/translations.ts | 21 + .../timeline/expandable_event/index.tsx | 3 + .../components/timeline/index.test.tsx | 1 + .../timeline/properties/index.test.tsx | 1 + .../properties/use_create_timeline.test.tsx | 20 +- .../properties/use_create_timeline.tsx | 19 +- .../components/timeline/timeline.test.tsx | 1 + .../timeline/epic_local_storage.test.tsx | 1 + 179 files changed, 1927 insertions(+), 870 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exit_full_screen/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/pages/display.tsx diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index e5dd109007eab..b39a038c4cc3c 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -32,6 +32,8 @@ export const DEFAULT_INTERVAL_PAUSE = true; export const DEFAULT_INTERVAL_TYPE = 'manual'; export const DEFAULT_INTERVAL_VALUE = 300000; // ms export const DEFAULT_TIMEPICKER_QUICK_RANGES = 'timepicker:quickRanges'; +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-*'; diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 8f03945df437c..41b9252c67b8a 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -32,7 +32,7 @@ Main.displayName = 'Main'; const usersViewing = ['elastic']; // TODO: get the users viewing this timeline from Elasticsearch (persistance) /** the global Kibana navigation at the top of every page */ -const globalHeaderHeightPx = 48; +export const globalHeaderHeightPx = 48; const calculateFlyoutHeight = ({ globalHeaderSize, diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx index 9db8adbf9346f..654a5f5c4a599 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import '../../../common/mock/match_media'; import { ExternalServiceColumn } from './columns'; import { useGetCasesMockState } from '../../containers/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index d8acda8ec4f33..23cabd6778cc0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { mount } from 'enzyme'; import moment from 'moment-timezone'; + +import '../../../common/mock/match_media'; import { AllCases } from '.'; import { TestProviders } from '../../../common/mock'; import { useGetCasesMockState } from '../../containers/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx index f4fd7cc67224f..b93de014f5c18 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.test.tsx @@ -5,6 +5,7 @@ */ import { mount } from 'enzyme'; import React from 'react'; +import '../../../common/mock/match_media'; import { AllCasesModal } from '.'; import { TestProviders } from '../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 2832a28fbb7cd..b93df325b5a8b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; +import '../../../common/mock/match_media'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; import { CaseComponent, CaseProps, CaseView } from '.'; import { basicCase, basicCaseClosed, caseUserActions } from '../../containers/mock'; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx index 8d14b2357f450..6fb693e47560d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/button.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { ReactWrapper, mount } from 'enzyme'; import { EuiText } from '@elastic/eui'; +import '../../../common/mock/match_media'; import { ConfigureCaseButton, ConfigureCaseButtonProps } from './button'; import { TestProviders } from '../../../common/mock'; import { searchURL } from './__mock__'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index d17a2bd215910..eb80eaff578f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -6,6 +6,8 @@ /* eslint-disable react/display-name */ import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; + +import '../../../common/mock/match_media'; import { usePushToService, ReturnUsePushToService, UsePushToService } from '.'; import { TestProviders } from '../../../common/mock'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 841a1ef09ede6..e30560f6c8147 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -58,6 +58,7 @@ const defaultAlertsFilters: Filter[] = [ interface Props { timelineId: TimelineIdLiteral; endDate: string; + eventsViewerBodyHeight?: number; startDate: string; pageFilters?: Filter[]; } @@ -65,6 +66,7 @@ interface Props { const AlertsTableComponent: React.FC = ({ timelineId, endDate, + eventsViewerBodyHeight, startDate, pageFilters = [], }) => { @@ -91,6 +93,7 @@ const AlertsTableComponent: React.FC = ({ pageFilters={alertsFilter} defaultModel={alertsDefaultModel} end={endDate} + height={eventsViewerBodyHeight} id={timelineId} start={startDate} /> diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx index a31cb4f2a8bfd..832b14f00159a 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx @@ -5,8 +5,18 @@ */ import React, { useEffect, useCallback, useMemo } from 'react'; import numeral from '@elastic/numeral'; +import { useWindowSize } from 'react-use'; + +import { globalHeaderHeightPx } from '../../../app/home'; +import { DEFAULT_NUMBER_FORMAT, FILTERS_GLOBAL_HEIGHT } from '../../../../common/constants'; +import { useFullScreen } from '../../containers/use_full_screen'; +import { EVENTS_VIEWER_HEADER_HEIGHT } from '../events_viewer/events_viewer'; +import { + getEventsViewerBodyHeight, + MIN_EVENTS_VIEWER_BODY_HEIGHT, +} from '../../../timelines/components/timeline/body/helpers'; +import { footerHeight } from '../../../timelines/components/timeline/footer'; -import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; import { AlertsComponentsProps } from './types'; import { AlertsTable } from './alerts_table'; import * as i18n from './translations'; @@ -35,6 +45,8 @@ export const AlertsView = ({ // eslint-disable-next-line react-hooks/exhaustive-deps [] ); + const { height: windowHeight } = useWindowSize(); + const { globalFullScreen } = useFullScreen(); const alertsHistogramConfigs: MatrixHisrogramConfigs = useMemo( () => ({ ...histogramConfigs, @@ -52,19 +64,32 @@ export const AlertsView = ({ return ( <> - + {!globalFullScreen && ( + + )} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index c2e8e56084452..cfe23b9391ec0 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../common/mock/match_media'; import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; import { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx index 49c421c5680ba..8617388f4ffb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx @@ -12,6 +12,7 @@ import { ThemeProvider } from 'styled-components'; import { escapeDataProviderId } from '../drag_and_drop/helpers'; import { TestProviders } from '../../mock'; +import '../../mock/match_media'; import { BarChartBaseComponent, BarChartComponent } from './barchart'; import { ChartSeriesData } from './common'; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index a11fdda3d1b3a..8fd2fa1fdef12 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -9,6 +9,7 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../mock/match_media'; import { TestProviders } from '../../mock'; import { MIN_LEGEND_HEIGHT, DraggableLegend } from './draggable_legend'; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 8ff75c8ca0780..9f6e614c3c285 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -9,6 +9,7 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../mock/match_media'; import { TestProviders } from '../../mock'; import { DraggableLegendItem, LegendItem } from './draggable_legend_item'; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx index d1b3b671307d1..da68280ed760c 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import { DraggableStateSnapshot, DraggingStyle } from 'react-beautiful-dnd'; +import '../../mock/match_media'; import { mockBrowserFields, mocksSource } from '../../containers/source/mock'; import { TestProviders } from '../../mock'; import { mockDataProviders } from '../../../timelines/components/timeline/data_providers/mock/mock_data_providers'; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index 432e369cdd0f6..3f06a8168b5ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { useWithSource } from '../../containers/source'; import { mockBrowserFields } from '../../containers/source/mock'; +import '../../mock/match_media'; import { useKibana } from '../../lib/kibana'; import { TestProviders } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx index 3d80a2605418e..ff1679875865c 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../mock'; +import '../../mock/match_media'; import { getEmptyString } from '../empty_value'; import { useMountAppended } from '../../utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index 9ca9cd6cce389..ebaf60e7078f0 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -4,6 +4,33 @@ exports[`EventDetails rendering should match snapshot 1`] = `
+ + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + repositionOnScroll={true} + /> + { data={mockDetailItemData} id={mockDetailItemDataId} view="table-view" + onEventToggled={jest.fn()} onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" @@ -50,6 +52,7 @@ describe('EventDetails', () => { data={mockDetailItemData} id={mockDetailItemDataId} view="table-view" + onEventToggled={jest.fn()} onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" @@ -76,6 +79,7 @@ describe('EventDetails', () => { data={mockDetailItemData} id={mockDetailItemDataId} view="table-view" + onEventToggled={jest.fn()} onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" @@ -88,5 +92,31 @@ describe('EventDetails', () => { wrapper.find('[data-test-subj="eventDetails"]').find('.euiTab-isSelected').first().text() ).toEqual('Table'); }); + + test('it invokes `onEventToggled` when the collapse button is clicked', () => { + const onEventToggled = jest.fn(); + + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="collapse"]').first().simulate('click'); + wrapper.update(); + + expect(onEventToggled).toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index c28757a90c702..53ec14380d5bc 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -4,8 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; -import React from 'react'; +import { noop } from 'lodash/fp'; +import { + EuiButtonIcon, + EuiPopover, + EuiTabbedContent, + EuiTabbedContentTab, + EuiToolTip, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; @@ -15,15 +22,34 @@ import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; import { EventFieldsBrowser } from './event_fields_browser'; import { JsonView } from './json_view'; import * as i18n from './translations'; +import { COLLAPSE, COLLAPSE_EVENT } from '../../../timelines/components/timeline/body/translations'; export type View = 'table-view' | 'json-view'; +const PopoverContainer = styled.div` + left: -40px; + position: relative; + top: 10px; + + .euiPopover { + position: fixed; + z-index: 10; + } +`; + +const CollapseButton = styled(EuiButtonIcon)` + border: 1px solid; +`; + +CollapseButton.displayName = 'CollapseButton'; + interface Props { browserFields: BrowserFields; columnHeaders: ColumnHeaderOptions[]; data: DetailItem[]; id: string; view: View; + onEventToggled: () => void; onUpdateColumns: OnUpdateColumns; onViewSelected: (selected: View) => void; timelineId: string; @@ -43,11 +69,27 @@ export const EventDetails = React.memo( data, id, view, + onEventToggled, onUpdateColumns, onViewSelected, timelineId, toggleColumn, }) => { + const button = useMemo( + () => ( + + + + ), + [onEventToggled] + ); + const tabs: EuiTabbedContentTab[] = [ { id: 'table-view', @@ -73,6 +115,14 @@ export const EventDetails = React.memo( return (
+ + + void; onUpdateColumns: OnUpdateColumns; timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; } export const StatefulEventDetails = React.memo( - ({ browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn }) => { + ({ + browserFields, + columnHeaders, + data, + id, + onEventToggled, + onUpdateColumns, + timelineId, + toggleColumn, + }) => { const [view, setView] = useState('table-view'); const handleSetView = useCallback((newView) => setView(newView), []); @@ -34,6 +44,7 @@ export const StatefulEventDetails = React.memo( columnHeaders={columnHeaders} data={data} id={id} + onEventToggled={onEventToggled} onUpdateColumns={onUpdateColumns} onViewSelected={handleSetView} timelineId={timelineId} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 674eb3325efc2..8c1f69279d31c 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; import useResizeObserver from 'use-resize-observer/polyfilled'; +import '../../mock/match_media'; import { mockIndexPattern, TestProviders } from '../../mock'; import { wait } from '../../lib/helpers'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 6e6ba4911be26..3f474da102ca4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { getOr, isEmpty, union } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { BrowserFields, DocValueFields } from '../../containers/source'; @@ -34,13 +34,40 @@ import { } from '../../../../../../../src/plugins/data/public'; import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; +import { ExitFullScreen } from '../exit_full_screen'; +import { useFullScreen } from '../../containers/use_full_screen'; +import { TimelineId } from '../../../../common/types/timeline'; + +export const EVENTS_VIEWER_HEADER_HEIGHT = 90; // px +const UTILITY_BAR_HEIGHT = 19; // px +const COMPACT_HEADER_HEIGHT = EVENTS_VIEWER_HEADER_HEIGHT - UTILITY_BAR_HEIGHT; // px + +const UtilityBar = styled.div` + height: ${UTILITY_BAR_HEIGHT}px; +`; + +const TitleText = styled.span` + margin-right: 12px; +`; const DEFAULT_EVENTS_VIEWER_HEIGHT = 500; -const StyledEuiPanel = styled(EuiPanel)` +const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>` + ${({ $isFullScreen }) => + $isFullScreen && + css` + border: 0; + box-shadow: none; + padding-top: 0; + padding-bottom: 0; + `} max-width: 100%; `; +const TitleFlexGroup = styled(EuiFlexGroup)` + margin-top: 8px; +`; + const EventsContainerLoading = styled.div` width: 100%; overflow: auto; @@ -98,6 +125,7 @@ const EventsViewerComponent: React.FC = ({ utilityBar, graphEventId, }) => { + const { globalFullScreen } = useFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const [isQueryLoading, setIsQueryLoading] = useState(false); @@ -113,6 +141,20 @@ const EventsViewerComponent: React.FC = ({ id, ]); + const justTitle = useMemo(() => {title}, [title]); + + const titleWithExitFullScreen = useMemo( + () => ( + + {justTitle} + + + + + ), + [justTitle] + ); + const combinedQueries = combineQueries({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, @@ -153,7 +195,10 @@ const EventsViewerComponent: React.FC = ({ ); return ( - + {canQueryTimeline ? ( = ({ return ( <> - + {headerFilterGroup} - {utilityBar?.(refetch, totalCountMinusDeleted)} + {utilityBar && ( + {utilityBar?.(refetch, totalCountMinusDeleted)} + )} = ({ excludedRowRendererIds, filters, headerFilterGroup, + height, id, isLive, itemsPerPage, @@ -128,6 +130,7 @@ const StatefulEventsViewerComponent: React.FC = ({ isLoadingIndexPattern={isLoadingIndexPattern} filters={globalFilters} headerFilterGroup={headerFilterGroup} + height={height} indexPattern={indexPatterns} isLive={isLive} itemsPerPage={itemsPerPage!} @@ -203,6 +206,7 @@ type PropsFromRedux = ConnectedProps; export const StatefulEventsViewer = connector( React.memo( StatefulEventsViewerComponent, + // eslint-disable-next-line complexity (prevProps, nextProps) => prevProps.id === nextProps.id && deepEqual(prevProps.columns, nextProps.columns) && @@ -212,6 +216,7 @@ export const StatefulEventsViewer = connector( prevProps.deletedEventIds === nextProps.deletedEventIds && prevProps.end === nextProps.end && deepEqual(prevProps.filters, nextProps.filters) && + prevProps.height === nextProps.height && prevProps.isLive === nextProps.isLive && prevProps.itemsPerPage === nextProps.itemsPerPage && deepEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && diff --git a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx new file mode 100644 index 0000000000000..8c5ad95a8de0e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiButton, EuiWindowEvent } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { useFullScreen } from '../../../common/containers/use_full_screen'; + +import * as i18n from './translations'; + +export const ExitFullScreen: React.FC = () => { + const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); + + const exitFullScreen = useCallback(() => { + setGlobalFullScreen(false); + }, [setGlobalFullScreen]); + + const onKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + + exitFullScreen(); + } + }, + [exitFullScreen] + ); + + if (!globalFullScreen) { + return null; + } + + return ( + <> + + + {i18n.EXIT_FULL_SCREEN} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/translations.ts b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/translations.ts new file mode 100644 index 0000000000000..72d451cfdfc14 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/translations.ts @@ -0,0 +1,11 @@ +/* + * 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 EXIT_FULL_SCREEN = i18n.translate('xpack.securitySolution.exitFullScreenButton', { + defaultMessage: 'Exit full screen', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx index b4d8c790002b2..65901ec589daf 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { Sticky } from 'react-sticky'; import styled, { css } from 'styled-components'; +import { FILTERS_GLOBAL_HEIGHT } from '../../../../common/constants'; import { gutterTimeline } from '../../lib/helpers'; const offsetChrome = 49; @@ -17,6 +18,7 @@ const disableSticky = `screen and (max-width: ${euiLightVars.euiBreakpoints.s})` const disableStickyMq = window.matchMedia(disableSticky); const Wrapper = styled.aside<{ isSticky?: boolean }>` + height: ${FILTERS_GLOBAL_HEIGHT}px; position: relative; z-index: ${({ theme }) => theme.eui.euiZNavigation}; background: ${({ theme }) => theme.eui.euiColorEmptyShade}; diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index ba4f782499802..3a8f2f0c16b96 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -17,17 +17,19 @@ import { MlPopover } from '../ml_popover/ml_popover'; import { SiemNavigation } from '../navigation'; import * as i18n from './translations'; import { useWithSource } from '../../containers/source'; +import { useFullScreen } from '../../containers/use_full_screen'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { useKibana } from '../../lib/kibana'; import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; import { LinkAnchor } from '../links'; -const Wrapper = styled.header` - ${({ theme }) => css` +const Wrapper = styled.header<{ show: boolean }>` + ${({ show, theme }) => css` background: ${theme.eui.euiColorEmptyShade}; border-bottom: ${theme.eui.euiBorderThin}; padding: ${theme.eui.paddingSizes.m} ${gutterTimeline} ${theme.eui.paddingSizes.m} ${theme.eui.paddingSizes.l}; + ${show ? '' : 'display: none;'}; `} `; Wrapper.displayName = 'Wrapper'; @@ -42,6 +44,7 @@ interface HeaderGlobalProps { } export const HeaderGlobal = React.memo(({ hideDetectionEngine = false }) => { const { indicesExist } = useWithSource(); + const { globalFullScreen } = useFullScreen(); const search = useGetUrlSearch(navTabs.overview); const { navigateToApp } = useKibana().services.application; const goToOverview = useCallback( @@ -53,7 +56,7 @@ export const HeaderGlobal = React.memo(({ hideDetectionEngine ); return ( - + <> diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/editable_title.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/editable_title.test.tsx index 1e9a2e06474b9..30e992380e7c6 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/editable_title.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/editable_title.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../mock/match_media'; import { TestProviders } from '../../mock'; import { EditableTitle } from './editable_title'; import { useMountAppended } from '../../utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx index 30f510509913a..15711663116f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.test.tsx @@ -8,6 +8,7 @@ import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import { shallow } from 'enzyme'; import React from 'react'; +import '../../mock/match_media'; import { TestProviders } from '../../mock'; import { HeaderPage } from './index'; import { useMountAppended } from '../../utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx index 5187a32ac9721..fd7a0a5d96e00 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/title.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../mock/match_media'; import { TestProviders } from '../../mock'; import { Title } from './title'; import { useMountAppended } from '../../utils/use_mount_appended'; 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 53b41e2240de2..f2d2d23d60fb1 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 @@ -1,7 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`HeaderSection it renders 1`] = ` -
+
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 43245121dd393..f49001bd5d7af 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 @@ -13,12 +13,18 @@ import { Subtitle } from '../subtitle'; interface HeaderProps { border?: boolean; + height?: number; } const Header = styled.header.attrs(() => ({ className: 'siemHeaderSection', }))` - margin-bottom: ${({ theme }) => theme.eui.euiSizeL}; +${({ height }) => + height && + css` + height: ${height}px; + `} + margin-bottom: ${({ height, theme }) => (height ? 0 : theme.eui.euiSizeL)}; user-select: text; ${({ border }) => @@ -32,6 +38,7 @@ Header.displayName = 'Header'; export interface HeaderSectionProps extends HeaderProps { children?: React.ReactNode; + height?: number; id?: string; split?: boolean; subtitle?: string | React.ReactNode; @@ -43,6 +50,7 @@ export interface HeaderSectionProps extends HeaderProps { const HeaderSectionComponent: React.FC = ({ border, children, + height, id, split, subtitle, @@ -50,7 +58,7 @@ const HeaderSectionComponent: React.FC = ({ titleSize = 'm', tooltip, }) => ( -
+
diff --git a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx index c48a5590b49cf..e9940d088e606 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/entity_draggable.test.tsx @@ -6,6 +6,8 @@ import React from 'react'; import { shallow } from 'enzyme'; + +import '../../mock/match_media'; import { EntityDraggableComponent } from './entity_draggable'; import { TestProviders } from '../../mock/test_providers'; import { useMountAppended } from '../../utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx index f7fa0ac0a8be1..434cbd8ada88e 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_score.test.tsx @@ -7,6 +7,8 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; + +import '../../../mock/match_media'; import { AnomalyScoreComponent } from './anomaly_score'; import { mockAnomalies } from '../mock'; import { TestProviders } from '../../../mock/test_providers'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx index d0b923002d6d4..a900c3e49f912 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/anomaly_scores.test.tsx @@ -7,6 +7,8 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; + +import '../../../mock/match_media'; import { AnomalyScoresComponent, createJobKey } from './anomaly_scores'; import { mockAnomalies } from '../mock'; import { TestProviders } from '../../../mock/test_providers'; diff --git a/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.test.tsx index f7759bb74c3ab..673d1a1cdb72e 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/score/draggable_score.test.tsx @@ -5,10 +5,12 @@ */ import React from 'react'; -import { mockAnomalies } from '../mock'; import { cloneDeep } from 'lodash/fp'; import { shallow } from 'enzyme'; + +import '../../../mock/match_media'; import { DraggableScoreComponent } from './draggable_score'; +import { mockAnomalies } from '../mock'; describe('draggable_score', () => { let anomalies = cloneDeep(mockAnomalies); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx index b90946c534f3a..d370a901a6262 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_host_table_columns.test.tsx @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; + +import '../../../mock/match_media'; import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns'; import { HostsType } from '../../../../hosts/store/model'; import * as i18n from './translations'; import { AnomaliesByHost, Anomaly } from '../types'; import { Columns } from '../../paginated_table'; import { TestProviders } from '../../../mock'; -import React from 'react'; import { useMountAppended } from '../../../utils/use_mount_appended'; const startDate = new Date(2001).toISOString(); diff --git a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx index 79277c46e1c9d..69a4e383413f2 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml/tables/get_anomalies_network_table_columns.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import '../../../mock/match_media'; import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns'; import { NetworkType } from '../../../../network/store/model'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/common/components/page/index.tsx b/x-pack/plugins/security_solution/public/common/components/page/index.tsx index f539bb7831c1c..9a5654ed6475f 100644 --- a/x-pack/plugins/security_solution/public/common/components/page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/page/index.tsx @@ -7,11 +7,13 @@ import { EuiBadge, EuiDescriptionList, EuiFlexGroup, EuiIcon, EuiPage } from '@elastic/eui'; import styled, { createGlobalStyle } from 'styled-components'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; + /* SIDE EFFECT: the following `createGlobalStyle` overrides default styling in angular code that was not theme-friendly and `EuiPopover`, `EuiToolTip` global styles */ -export const AppGlobalStyle = createGlobalStyle` +export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimary: string } } }>` /* dirty hack to fix draggables with tooltip on FF */ body#siem-app { position: static; @@ -57,6 +59,10 @@ export const AppGlobalStyle = createGlobalStyle` z-index: 9950; } + /** applies a "toggled" button style to the Full Screen button */ + .${FULL_SCREEN_TOGGLED_CLASS_NAME} { + ${({ theme }) => `background-color: ${theme.eui.euiColorPrimary} !important`}; + } `; export const DescriptionListStyled = styled(EuiDescriptionList)` diff --git a/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx index 7ceb34755648e..b28c7e70b8ae8 100644 --- a/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/tables/helpers.test.tsx @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import '../../mock/match_media'; import { getRowItemDraggables, getRowItemOverflow, getRowItemDraggable, OverflowFieldComponent, } from './helpers'; -import React from 'react'; -import { shallow } from 'enzyme'; import { TestProviders } from '../../mock'; import { getEmptyValue } from '../empty_value'; import { useMountAppended } from '../../utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index b393e9ae6319b..1e93fdb936728 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -7,6 +7,7 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; +import '../../mock/match_media'; import { mockBrowserFields } from '../../containers/source/mock'; import { apolloClientObservable, diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx index e5a1fb6120285..667d1816e8f07 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/top_n.test.tsx @@ -7,6 +7,7 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; +import '../../mock/match_media'; import { TestProviders, mockIndexPattern } from '../../mock'; import { setAbsoluteRangeDatePicker } from '../../store/inputs/actions'; diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 3223c5058fa7f..03f9b43678003 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx @@ -5,9 +5,10 @@ */ import classNames from 'classnames'; -import React from 'react'; +import React, { useEffect } from 'react'; import styled from 'styled-components'; +import { useFullScreen } from '../../containers/use_full_screen'; import { gutterTimeline } from '../../lib/helpers'; import { AppGlobalStyle } from '../page/index'; @@ -45,6 +46,11 @@ const WrapperPageComponent: React.FC = ({ style, noPadding, }) => { + const { setGlobalFullScreen } = useFullScreen(); + useEffect(() => { + setGlobalFullScreen(false); // exit full screen mode on page load + }, [setGlobalFullScreen]); + const classes = classNames(className, { siemWrapperPage: true, 'siemWrapperPage--restrictWidthDefault': diff --git a/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx new file mode 100644 index 0000000000000..b8050034d34a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx @@ -0,0 +1,39 @@ +/* + * 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 { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { inputsSelectors } from '../../store'; +import { inputsActions } from '../../store/actions'; + +export const useFullScreen = () => { + const dispatch = useDispatch(); + const globalFullScreen = useSelector(inputsSelectors.globalFullScreenSelector) ?? false; + const timelineFullScreen = useSelector(inputsSelectors.timelineFullScreenSelector) ?? false; + + const setGlobalFullScreen = useCallback( + (fullScreen: boolean) => dispatch(inputsActions.setFullScreen({ id: 'global', fullScreen })), + [dispatch] + ); + + const setTimelineFullScreen = useCallback( + (fullScreen: boolean) => dispatch(inputsActions.setFullScreen({ id: 'timeline', fullScreen })), + [dispatch] + ); + + const memoizedReturn = useMemo( + () => ({ + globalFullScreen, + setGlobalFullScreen, + setTimelineFullScreen, + timelineFullScreen, + }), + [globalFullScreen, setGlobalFullScreen, setTimelineFullScreen, timelineFullScreen] + ); + + return memoizedReturn; +}; diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts index efad0638b2971..5d00882f778c0 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/actions.ts @@ -37,6 +37,11 @@ export const startAutoReload = actionCreator<{ id: InputsModelId }>('START_KQL_A export const stopAutoReload = actionCreator<{ id: InputsModelId }>('STOP_KQL_AUTO_RELOAD'); +export const setFullScreen = actionCreator<{ + id: InputsModelId; + fullScreen: boolean; +}>('SET_FULL_SCREEN'); + export const setQuery = actionCreator<{ inputId: InputsModelId; id: string; diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts b/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts index 1883f05dc9e9d..82a2072056d9f 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/helpers.ts @@ -9,6 +9,22 @@ import { get } from 'lodash/fp'; import { InputsModel, TimeRange, Refetch, RefetchKql, InspectQuery } from './model'; import { InputsModelId } from './constants'; +export const updateInputFullScreen = ( + inputId: InputsModelId, + fullScreen: boolean, + state: InputsModel +): InputsModel => ({ + ...state, + global: { + ...state.global, + fullScreen: inputId === 'global' ? fullScreen : state.global.fullScreen, + }, + timeline: { + ...state.timeline, + fullScreen: inputId === 'timeline' ? fullScreen : state.timeline.fullScreen, + }, +}); + export const updateInputTimerange = ( inputId: InputsModelId, timerange: TimeRange, diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts index 358124405c146..a8db48c7b31bb 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/model.ts @@ -80,6 +80,7 @@ export interface InputsRange { query: Query; filters: Filter[]; savedQuery?: SavedQuery; + fullScreen?: boolean; } export interface LinkTo { diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts index 40d9ad777acde..a94f0f6ca24ee 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/reducer.ts @@ -12,6 +12,7 @@ import { deleteAllQuery, setAbsoluteRangeDatePicker, setDuration, + setFullScreen, setInspectionParameter, setQuery, setRelativeRangeDatePicker, @@ -38,6 +39,7 @@ import { removeTimelineLink, addTimelineLink, deleteOneQuery as helperDeleteOneQuery, + updateInputFullScreen, } from './helpers'; import { InputsModel, TimeRange } from './model'; @@ -57,6 +59,7 @@ export const initialInputsState: InputsState = { language: 'kuery', }, filters: [], + fullScreen: false, }, timeline: { timerange: { @@ -71,6 +74,7 @@ export const initialInputsState: InputsState = { language: 'kuery', }, filters: [], + fullScreen: false, }, }; @@ -98,6 +102,7 @@ export const createInitialInputsState = (): InputsState => { language: 'kuery', }, filters: [], + fullScreen: false, }, timeline: { timerange: { @@ -118,6 +123,7 @@ export const createInitialInputsState = (): InputsState => { language: 'kuery', }, filters: [], + fullScreen: false, }, }; }; @@ -163,6 +169,9 @@ export const inputsReducer = reducerWithInitialState(initialInputsState) }; return updateInputTimerange(id, timerange, state); }) + .case(setFullScreen, (state, { id, fullScreen }) => { + return updateInputFullScreen(id, fullScreen, state); + }) .case(deleteAllQuery, (state, { id }) => ({ ...state, [id]: { diff --git a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts index 0eee5ebbfbf77..9feb2f87d7e08 100644 --- a/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/inputs/selectors.ts @@ -44,6 +44,13 @@ export const timelineTimeRangeSelector = createSelector( (timeline) => timeline.timerange ); +export const globalFullScreenSelector = createSelector(selectGlobal, (global) => global.fullScreen); + +export const timelineFullScreenSelector = createSelector( + selectTimeline, + (timeline) => timeline.fullScreen +); + export const globalTimeRangeSelector = createSelector(selectGlobal, (global) => global.timerange); export const globalPolicySelector = createSelector(selectGlobal, (global) => global.policy); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/alerts_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/alerts_histogram.test.tsx index 09883e342f998..692d22b115b48 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/alerts_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/alerts_histogram.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../common/mock/match_media'; import { AlertsHistogram } from './alerts_histogram'; jest.mock('../../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx index 4cbfa59aac582..533f13e6781a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_histogram_panel/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../common/mock/match_media'; import { AlertsHistogramPanel } from './index'; jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx index cc3a47017a835..d5688d84e9759 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../common/mock/match_media'; import { TimelineId } from '../../../../common/types/timeline'; import { TestProviders } from '../../../common/mock'; import { AlertsTableComponent } from './index'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 405ba0719a910..30cfe2d02354f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -61,6 +61,7 @@ interface OwnProps { timelineId: TimelineIdLiteral; canUserCRUD: boolean; defaultFilters?: Filter[]; + eventsViewerBodyHeight?: number; hasIndexWrite: boolean; from: string; loading: boolean; @@ -86,6 +87,7 @@ export const AlertsTableComponent: React.FC = ({ clearEventsLoading, clearSelected, defaultFilters, + eventsViewerBodyHeight, from, globalFilters, globalQuery, @@ -443,6 +445,7 @@ export const AlertsTableComponent: React.FC = ({ defaultModel={alertsDefaultModel} end={to} headerFilterGroup={headerFilterGroup} + height={eventsViewerBodyHeight} id={timelineId} start={from} utilityBar={utilityBarCallback} diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx index a2685017f86d6..efce1dc026353 100644 --- a/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/detection_engine_header_page/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../common/mock/match_media'; import { DetectionEngineHeaderPage } from './index'; describe('detection_engine_header_page', () => { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx index d841af69a7537..59334b53faa17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/all_rules_tables/index.test.tsx @@ -7,6 +7,7 @@ import React, { useRef } from 'react'; import { shallow } from 'enzyme'; +import '../../../../common/mock/match_media'; import { AllRulesTables } from './index'; import { AllRulesTabs } from '../../../pages/detection_engine/rules/all'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx index 89f6399071dd3..a41da908085bc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/load_empty_prompt.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../../common/mock/match_media'; import { PrePackagedRulesPrompt } from './load_empty_prompt'; jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index f4004a66c8f80..e7a8c4854fa9e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -5,15 +5,33 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount } from 'enzyme'; import { useParams } from 'react-router-dom'; import '../../../common/mock/match_media'; +import { + apolloClientObservable, + createSecuritySolutionStorageMock, + kibanaObservable, + mockGlobalState, + TestProviders, + SUB_PLUGINS_REDUCER, +} from '../../../common/mock'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { DetectionEnginePageComponent } from './detection_engine'; import { useUserInfo } from '../../components/user_info'; import { useWithSource } from '../../../common/containers/source'; +import { createStore, State } from '../../../common/store'; +import { mockHistory, Router } from '../../../cases/components/__mock__/router'; +// Test will fail because we will to need to mock some core services to make the test work +// For now let's forget about SiemSearchBar and QueryBar +jest.mock('../../../common/components/search_bar', () => ({ + SiemSearchBar: () => null, +})); +jest.mock('../../../common/components/query_bar', () => ({ + QueryBar: () => null, +})); jest.mock('../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../components/user_info'); jest.mock('../../../common/containers/source'); @@ -36,6 +54,19 @@ jest.mock('react-router-dom', () => { }; }); +const state: State = { + ...mockGlobalState, +}; + +const { storage } = createSecuritySolutionStorageMock(); +const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage +); + describe('DetectionEnginePageComponent', () => { beforeAll(() => { (useParams as jest.Mock).mockReturnValue({}); @@ -47,14 +78,18 @@ describe('DetectionEnginePageComponent', () => { }); it('renders correctly', () => { - const wrapper = shallow( - + const wrapper = mount( + + + + + ); - expect(wrapper.find('FiltersGlobal')).toHaveLength(1); + expect(wrapper.find('FiltersGlobal').exists()).toBe(true); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index aef9f2adcbcc8..acafb15db3448 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -4,12 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import React, { useCallback, useMemo, useState } from 'react'; import { StickyContainer } from 'react-sticky'; import { connect, ConnectedProps } from 'react-redux'; - +import { useWindowSize } from 'react-use'; import { useHistory } from 'react-router-dom'; + +import { globalHeaderHeightPx } from '../../../app/home'; import { SecurityPageName } from '../../../app/types'; import { TimelineId } from '../../../../common/types/timeline'; import { useGlobalTime } from '../../../common/containers/use_global_time'; @@ -31,6 +34,7 @@ import { NoWriteAlertsCallOut } from '../../components/no_write_alerts_callout'; import { AlertsHistogramPanel } from '../../components/alerts_histogram_panel'; import { alertsHistogramOptions } from '../../components/alerts_histogram_panel/config'; import { useUserInfo } from '../../components/user_info'; +import { EVENTS_VIEWER_HEADER_HEIGHT } from '../../../common/components/events_viewer/events_viewer'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; @@ -39,6 +43,14 @@ import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unau import * as i18n from './translations'; import { LinkButton } from '../../../common/components/links'; import { useFormatUrl } from '../../../common/components/link_to'; +import { FILTERS_GLOBAL_HEIGHT } from '../../../../common/constants'; +import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { Display } from '../../../hosts/pages/display'; +import { + getEventsViewerBodyHeight, + MIN_EVENTS_VIEWER_BODY_HEIGHT, +} from '../../../timelines/components/timeline/body/helpers'; +import { footerHeight } from '../../../timelines/components/timeline/footer'; import { buildShowBuildingBlockFilter } from '../../components/alerts_table/default_config'; export const DetectionEnginePageComponent: React.FC = ({ @@ -47,6 +59,8 @@ export const DetectionEnginePageComponent: React.FC = ({ setAbsoluteRangeDatePicker, }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); + const { height: windowHeight } = useWindowSize(); + const { globalFullScreen } = useFullScreen(); const { loading: userInfoLoading, isSignalIndexExists, @@ -136,51 +150,66 @@ export const DetectionEnginePageComponent: React.FC = ({ {hasIndexWrite != null && !hasIndexWrite && } {indicesExist ? ( + - - - {i18n.LAST_ALERT} - {': '} - {lastAlerts} - - ) - } - title={i18n.PAGE_TITLE} - > - + + + {i18n.LAST_ALERT} + {': '} + {lastAlerts} + + ) + } + title={i18n.PAGE_TITLE} > - {i18n.BUTTON_MANAGE_RULES} - - + + {i18n.BUTTON_MANAGE_RULES} + + + + + - - ({ + SiemSearchBar: () => null, +})); +jest.mock('../../../../../common/components/query_bar', () => ({ + QueryBar: () => null, +})); jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); @@ -38,6 +55,18 @@ jest.mock('react-router-dom', () => { }; }); +const state: State = { + ...mockGlobalState, +}; +const { storage } = createSecuritySolutionStorageMock(); +const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage +); + describe('RuleDetailsPageComponent', () => { beforeAll(() => { (useUserInfo as jest.Mock).mockReturnValue({}); @@ -49,17 +78,21 @@ describe('RuleDetailsPageComponent', () => { }); it('renders correctly', () => { - const wrapper = shallow( - , + const wrapper = mount( + + + + + , { wrappingComponent: TestProviders, } ); - expect(wrapper.find('DetectionEngineHeaderPage')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="header-page-title"]').exists()).toBe(true); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 2e7ef1180f4e3..7eb5c3a535377 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -15,13 +15,17 @@ import { EuiTab, EuiTabs, EuiToolTip, + EuiWindowEvent, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { noop } from 'lodash/fp'; import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { StickyContainer } from 'react-sticky'; import { connect, ConnectedProps } from 'react-redux'; +import { useWindowSize } from 'react-use'; +import { globalHeaderHeightPx } from '../../../../../app/home'; import { TimelineId } from '../../../../../../common/types/timeline'; import { UpdateDateRange } from '../../../../../common/components/charts/common'; import { FiltersGlobal } from '../../../../../common/components/filters_global'; @@ -62,6 +66,7 @@ import * as ruleI18n from '../translations'; import * as i18n from './translations'; import { useGlobalTime } from '../../../../../common/containers/use_global_time'; import { alertsHistogramOptions } from '../../../../components/alerts_histogram_panel/config'; +import { EVENTS_VIEWER_HEADER_HEIGHT } from '../../../../../common/components/events_viewer/events_viewer'; import { inputsSelectors } from '../../../../../common/store/inputs'; import { State } from '../../../../../common/store'; import { InputsRange } from '../../../../../common/store/inputs/model'; @@ -76,7 +81,15 @@ import { SecurityPageName } from '../../../../../app/types'; import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; +import { FILTERS_GLOBAL_HEIGHT } from '../../../../../../common/constants'; +import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionIdentifiers } from '../../../../../lists_plugin_deps'; +import { + getEventsViewerBodyHeight, + MIN_EVENTS_VIEWER_BODY_HEIGHT, +} from '../../../../../timelines/components/timeline/body/helpers'; +import { footerHeight } from '../../../../../timelines/components/timeline/footer'; enum RuleDetailTabs { alerts = 'alerts', @@ -141,6 +154,8 @@ export const RuleDetailsPageComponent: FC = ({ const mlCapabilities = useMlCapabilities(); const history = useHistory(); const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const { height: windowHeight } = useWindowSize(); + const { globalFullScreen } = useFullScreen(); // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = @@ -329,140 +344,156 @@ export const RuleDetailsPageComponent: FC = ({ {userHasNoPermissions(canUserCRUD) && } {indicesExist ? ( + - - - {detectionI18n.LAST_ALERT} - {': '} - {lastAlerts} - , - ] - : []), - , - ]} - title={title} - > - - - - + + + {detectionI18n.LAST_ALERT} + {': '} + {lastAlerts} + , + ] + : []), + , + ]} + title={title} + > + + + - + > + + + + + + + + + {ruleI18n.EDIT_RULE_SETTINGS} + + + + + + + + + + {ruleError} + + + + - - - - - {ruleI18n.EDIT_RULE_SETTINGS} - + + + + + {defineRuleData != null && ( + + )} + - - + + + + {scheduleRuleData != null && ( + + )} + - - {ruleError} - - - - - - - - - - - {defineRuleData != null && ( - - )} - - - - - - {scheduleRuleData != null && ( - - )} - - - - - - - {tabs} - + + {tabs} + + {ruleDetailTab === RuleDetailTabs.alerts && ( <> - - + + + + {ruleId != null && ( ` + ${({ show }) => (show ? '' : 'display: none;')}; +`; + +Display.displayName = 'Display'; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index b37d91cc2be3b..a3885eac5377c 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import React, { useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { StickyContainer } from 'react-sticky'; @@ -22,6 +23,7 @@ import { manageQuery } from '../../common/components/page/manage_query'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { KpiHostsQuery } from '../containers/kpi_hosts'; +import { useFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useWithSource } from '../../common/containers/source'; import { LastEventIndexKey } from '../../graphql/types'; @@ -34,6 +36,7 @@ import { SpyRoute } from '../../common/utils/route/spy_routes'; import { esQuery } from '../../../../../../src/plugins/data/public'; import { useMlCapabilities } from '../../common/components/ml_popover/hooks/use_ml_capabilities'; import { OverviewEmpty } from '../../overview/components/overview_empty'; +import { Display } from './display'; import { HostsTabs } from './hosts_tabs'; import { navTabsHosts } from './nav_tabs'; import * as i18n from './translations'; @@ -47,6 +50,7 @@ const KpiHostsComponentManage = manageQuery(KpiHostsComponent); export const HostsComponent = React.memo( ({ filters, query, setAbsoluteRangeDatePicker, hostsPagePath }) => { const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useFullScreen(); const capabilities = useMlCapabilities(); const kibana = useKibana(); const { tabName } = useParams(); @@ -88,44 +92,47 @@ export const HostsComponent = React.memo( <> {indicesExist ? ( + - - } - title={i18n.PAGE_TITLE} - /> - - - {({ kpiHosts, loading, id, inspect, refetch }) => ( - - )} - - - - - - - + + + } + title={i18n.PAGE_TITLE} + /> + + + {({ kpiHosts, loading, id, inspect, refetch }) => ( + + )} + + + + + + + + { const { initializeTimeline } = useManageTimeline(); const dispatch = useDispatch(); - + const { height: windowHeight } = useWindowSize(); + const { globalFullScreen } = useFullScreen(); useEffect(() => { initializeTimeline({ id: TimelineId.hostsPageEvents, @@ -81,19 +93,32 @@ export const EventsQueryTabBody = ({ return ( <> - + {!globalFullScreen && ( + + )} ( capabilitiesFetched, }) => { const { to, from, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useFullScreen(); const kibana = useKibana(); const { tabName } = useParams(); @@ -95,56 +99,61 @@ const NetworkComponent = React.memo( <> {indicesExist ? ( + - - } - title={i18n.PAGE_TITLE} - /> - - - - - - - {({ kpiNetwork, loading, id, inspect, refetch }) => ( - - )} - + + + } + title={i18n.PAGE_TITLE} + /> + + + + + + + {({ kpiNetwork, loading, id, inspect, refetch }) => ( + + )} + + {capabilitiesFetched && !isInitializing ? ( <> - + + - + - + + ( ) : ( )} - - ) : ( diff --git a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx index 8d004829a34f0..63126da0b9bb5 100644 --- a/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/alerts_by_category/index.test.tsx @@ -11,6 +11,7 @@ import { mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../common/mock/match_media'; import { useQuery } from '../../../common/containers/matrix_histogram'; import { wait } from '../../../common/lib/helpers'; import { mockIndexPattern, TestProviders } from '../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx index c4a941d845f16..8268a550257c9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/event_counts/index.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { OverviewHostProps } from '../overview_host'; import { OverviewNetworkProps } from '../overview_network'; import { mockIndexPattern, TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { EventCounts } from '.'; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx index 8e221445a95d3..fee38ad3c6289 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/index.test.tsx @@ -6,6 +6,8 @@ import { mount } from 'enzyme'; import React from 'react'; + +import '../../../../common/mock/match_media'; import { TestProviders } from '../../../../common/mock'; import { EndpointOverview } from './index'; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx index 71cf056f3eb62..6bd0390d014a3 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.test.tsx @@ -6,6 +6,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../common/mock/match_media'; import { TestProviders } from '../../../common/mock'; import { HostOverview } from './index'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index 5140137ce1b99..30874e8874760 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -9,6 +9,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import '../../../common/mock/match_media'; import { apolloClientObservable, mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index d2d823f625690..9ac4f7125f34d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -8,6 +8,7 @@ import { cloneDeep } from 'lodash/fp'; import { mount } from 'enzyme'; import React from 'react'; import { MockedProvider } from 'react-apollo/test-utils'; +import '../../../common/mock/match_media'; import { apolloClientObservable, mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.test.tsx index a5edffc2a099a..b31094b07a829 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/certificate_fingerprint/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { CertificateFingerprint } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx index 94123000888aa..c38eb23195c06 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/duration/index.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../common/mock/match_media'; import { TestProviders } from '../../../common/mock'; import { ONE_MILLISECOND_AS_NANOSECONDS } from '../formatted_duration/helpers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx index cf12740d93a18..c3b67e3300459 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/field_renderers/field_renderers.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { FlowTarget, GetIpOverviewQuery, HostEcsFields } from '../../../graphql/types'; import { TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { getEmptyValue } from '../../../common/components/empty_value'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx index 16174e92b3c37..62306046c7b8c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/category.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../common/mock/match_media'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { Category } from './category'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx index 7c4e3d435e1ed..9340ee8cf0c7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.test.tsx @@ -7,6 +7,7 @@ import { mount } from 'enzyme'; import React from 'react'; +import '../../../common/mock/match_media'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx index e4c9621c2f71c..f4f8adc9f0419 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_items.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../timeline/body/constants'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx index 1f917c664e813..44e4818830acd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_name.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers'; import { FieldName } from './field_name'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx index b55bbfc023774..c2ddba6bd88c3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/fields_pane.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../common/mock/match_media'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx index ed3f957ad11a8..a3c7440bece24 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/index.test.tsx @@ -7,6 +7,7 @@ import { mount } from 'enzyme'; import React from 'react'; +import '../../../common/mock/match_media'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { TestProviders } from '../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx index 9b7d4c3266c56..cfdca8950d314 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/index.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { TimelineType } from '../../../../../common/types/timeline'; import { TestProviders } from '../../../../common/mock'; +import '../../../../common/mock/match_media'; import { FlyoutHeaderWithCloseButton } from '.'; jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index 1616738897b0a..f41d318ba9587 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -10,11 +10,13 @@ import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { Resizable, ResizeCallback } from 're-resizable'; -import { TimelineResizeHandle } from './timeline_resize_handle'; import { EventDetailsWidthProvider } from '../../../../common/components/events_viewer/event_details_width_context'; +import { useFullScreen } from '../../../../common/containers/use_full_screen'; +import { timelineActions } from '../../../store/timeline'; + +import { TimelineResizeHandle } from './timeline_resize_handle'; import * as i18n from './translations'; -import { timelineActions } from '../../../store/timeline'; const minWidthPixels = 550; // do not allow the flyout to shrink below this width (pixels) const maxWidthPercent = 95; // do not allow the flyout to grow past this percentage of the view @@ -44,12 +46,12 @@ const RESIZABLE_ENABLE = { left: true }; const FlyoutPaneComponent: React.FC = ({ children, - flyoutHeight, onClose, timelineId, width, }) => { const dispatch = useDispatch(); + const { timelineFullScreen } = useFullScreen(); const onResizeStop: ResizeCallback = useCallback( (_e, _direction, _ref, delta) => { @@ -80,9 +82,9 @@ const FlyoutPaneComponent: React.FC = ({ ); const resizableHandleComponent = useMemo( () => ({ - left: , + left: , }), - [flyoutHeight] + [] ); return ( @@ -98,8 +100,8 @@ const FlyoutPaneComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx index 741ed0a09ebf6..7192580f2426d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/timeline_resize_handle.tsx @@ -6,15 +6,17 @@ import styled from 'styled-components'; -export const TIMELINE_RESIZE_HANDLE_WIDTH = 2; // px +export const TIMELINE_RESIZE_HANDLE_WIDTH = 4; // px -export const TimelineResizeHandle = styled.div<{ height: number }>` +export const TimelineResizeHandle = styled.div` + background-color: ${({ theme }) => theme.eui.euiColorLightShade}; cursor: col-resize; - height: 100%; min-height: 20px; - width: 0; - border: ${TIMELINE_RESIZE_HANDLE_WIDTH}px solid ${(props) => props.theme.eui.euiColorLightShade}; + width: ${TIMELINE_RESIZE_HANDLE_WIDTH}px; z-index: 2; - height: ${({ height }) => `${height}px`}; + height: 100vh; position: absolute; + &:hover { + background-color: ${({ theme }) => theme.eui.euiColorPrimary}; + } `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 085f0863c7b27..9f20c7f6c1571 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -4,21 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiToolTip, +} from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; import { SecurityPageName } from '../../../app/types'; +import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { AllCasesModal } from '../../../cases/components/all_cases_modal'; +import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; +import { APP_ID, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { useFullScreen } from '../../../common/containers/use_full_screen'; import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; -import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; import { State } from '../../../common/store'; +import { TimelineId, TimelineType } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; import { TimelineModel } from '../../store/timeline/model'; +import { isFullScreen } from '../timeline/body/column_headers'; import { NewCase, ExistingCase } from '../timeline/properties/helpers'; import { UNTITLED_TIMELINE } from '../timeline/properties/translations'; import { @@ -28,7 +40,6 @@ import { import { Resolver } from '../../../resolver/view'; import * as i18n from './translations'; -import { TimelineType } from '../../../../common/types/timeline'; const OverlayContainer = styled.div<{ bodyHeight?: number }>` height: ${({ bodyHeight }) => (bodyHeight ? `${bodyHeight}px` : 'auto')}; @@ -41,6 +52,10 @@ const StyledResolver = styled(Resolver)` height: 100%; `; +const FullScreenButtonIcon = styled(EuiButtonIcon)` + margin: 4px 0 4px 0; +`; + interface OwnProps { bodyHeight?: number; graphEventId?: string; @@ -48,6 +63,46 @@ interface OwnProps { timelineType: TimelineType; } +const Navigation = ({ + fullScreen, + globalFullScreen, + onCloseOverlay, + timelineId, + timelineFullScreen, + toggleFullScreen, +}: { + fullScreen: boolean; + globalFullScreen: boolean; + onCloseOverlay: () => void; + timelineId: string; + timelineFullScreen: boolean; + toggleFullScreen: () => void; +}) => ( + + + + {i18n.BACK_TO_EVENTS} + + + + + + + + +); + const GraphOverlayComponent = ({ bodyHeight, graphEventId, @@ -86,17 +141,45 @@ const GraphOverlayComponent = ({ }, [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] ); + const { + timelineFullScreen, + setTimelineFullScreen, + globalFullScreen, + setGlobalFullScreen, + } = useFullScreen(); + const fullScreen = useMemo( + () => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }), + [globalFullScreen, timelineId, timelineFullScreen] + ); + const toggleFullScreen = useCallback(() => { + if (timelineId === TimelineId.active) { + setTimelineFullScreen(!timelineFullScreen); + } else { + setGlobalFullScreen(!globalFullScreen); + } + }, [ + timelineId, + setTimelineFullScreen, + timelineFullScreen, + setGlobalFullScreen, + globalFullScreen, + ]); return ( - - {i18n.BACK_TO_EVENTS} - + - {timelineType === TimelineType.default && ( + {timelineId === TimelineId.active && timelineType === TimelineType.default && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx index 113c2dca97506..899a6d7486f94 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/ja3_fingerprint/index.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { TestProviders } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; import { Ja3Fingerprint } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx index 24f8d910b4feb..c2026a71ac6ff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/netflow/index.test.tsx @@ -10,6 +10,7 @@ import { shallow } from 'enzyme'; import { asArrayIfExists } from '../../../common/lib/helpers'; import { getMockNetflowData } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { TestProviders } from '../../../common/mock/test_providers'; import { TLS_CLIENT_CERTIFICATE_FINGERPRINT_SHA1_FIELD_NAME, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx index e2def46b936be..e671244d97b57 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.test.tsx @@ -9,6 +9,7 @@ import { MockedProvider } from 'react-apollo/test-utils'; import React from 'react'; import { wait } from '../../../common/lib/helpers'; +import '../../../common/mock/match_media'; import { TestProviders, apolloClient } from '../../../common/mock/test_providers'; import { mockOpenTimelineQueryResults } from '../../../common/mock/timeline_results'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines_page'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index f42914c86f46b..57a6431a06b90 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../common/mock/match_media'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../pages/timelines_page'; import { OpenTimelineResult, OpenTimelineProps } from './types'; import { TimelinesTableProps } from './timelines_table'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 1d08f0296ce0d..12df17ceba666 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../../common/mock/match_media'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines_page'; import { OpenTimelineResult, OpenTimelineProps } from '../types'; import { TimelinesTableProps } from '../timelines_table'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.test.tsx index 9bec06e5ed917..eddfdf6e01df2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -11,6 +11,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../../common/mock/match_media'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineResult } from '../types'; import { TimelinesTableProps } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx index 112329ac1738d..b8b2630e09c6e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import '../../../../common/mock/match_media'; import { getEmptyValue } from '../../../../common/components/empty_value'; import { OpenTimelineResult } from '../types'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx index 390ce8c0b6940..0f2b3cdea4eec 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../../common/mock/match_media'; import { getEmptyValue } from '../../../../common/components/empty_value'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineResult } from '../types'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx index f1df605c072dd..6e3f0037003b1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../../common/mock/match_media'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx index f230a964c3c2a..649e38865f907 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.test.tsx @@ -10,6 +10,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; +import '../../../../common/mock/match_media'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineResult } from '../types'; import { TimelinesTable, TimelinesTableProps } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index a5610cabc1774..13c2b14d26eca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -1,503 +1,591 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` - - - - - + + + + + + - - - - - - - - - - + ] + } + isSelectAllChecked={false} + onColumnRemoved={[MockFunction]} + onColumnResized={[MockFunction]} + onColumnSorted={[MockFunction]} + onSelectAll={[Function]} + onUpdateColumns={[MockFunction]} + showEventsSelect={false} + showSelectAllCheckbox={false} + sort={ + Object { + "columnId": "fooColumn", + "sortDirection": "desc", + } + } + timelineId="test" + toggleColumn={[MockFunction]} + /> + + + + + + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts index 588f407416803..21e135218c871 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/helpers.test.ts @@ -9,9 +9,10 @@ import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH, DEFAULT_ACTIONS_COLUMN_WIDTH, + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH, SHOW_CHECK_BOXES_COLUMN_WIDTH, - MINIMUM_ACTIONS_COLUMN_WIDTH, } from '../constants'; +import '../../../../../common/mock/match_media'; describe('helpers', () => { describe('getColumnWidthFromType', () => { @@ -36,12 +37,12 @@ describe('helpers', () => { }); test('returns the events viewer actions column width when isEventViewer is true', () => { - expect(getActionsColumnWidth(true)).toEqual(MINIMUM_ACTIONS_COLUMN_WIDTH); + expect(getActionsColumnWidth(true)).toEqual(EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH); }); test('returns the events viewer actions column width + checkbox width when isEventViewer is true and showCheckboxes is true', () => { expect(getActionsColumnWidth(true, true)).toEqual( - MINIMUM_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH + EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH + SHOW_CHECK_BOXES_COLUMN_WIDTH ); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx index 6a7734ce3161d..6685ce7d7a018 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; import { defaultHeaders } from './default_headers'; import { Direction } from '../../../../../graphql/types'; @@ -28,22 +29,24 @@ describe('ColumnHeaders', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + + + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index b139aa1a7a9a6..a3e177604fbd4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiCheckbox } from '@elastic/eui'; +import { EuiButtonIcon, EuiCheckbox, EuiToolTip } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Droppable, DraggableChildrenFn } from 'react-beautiful-dnd'; @@ -18,6 +18,10 @@ import { DRAG_TYPE_FIELD, droppableTimelineColumnsPrefix, } from '../../../../../common/components/drag_and_drop/helpers'; +import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants'; +import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { TimelineId } from '../../../../../../common/types/timeline'; import { OnColumnRemoved, OnColumnResized, @@ -42,6 +46,8 @@ import { Sort } from '../sort'; import { EventsSelect } from './events_select'; import { ColumnHeader } from './column_header'; +import * as i18n from './translations'; + interface Props { actionsColumnWidth: number; browserFields: BrowserFields; @@ -81,6 +87,18 @@ export const DraggableContainer = React.memo( DraggableContainer.displayName = 'DraggableContainer'; +export const isFullScreen = ({ + globalFullScreen, + timelineId, + timelineFullScreen, +}: { + globalFullScreen: boolean; + timelineId: string; + timelineFullScreen: boolean; +}) => + (timelineId === TimelineId.active && timelineFullScreen) || + (timelineId !== TimelineId.active && globalFullScreen); + /** Renders the timeline header columns */ export const ColumnHeadersComponent = ({ actionsColumnWidth, @@ -101,6 +119,26 @@ export const ColumnHeadersComponent = ({ toggleColumn, }: Props) => { const [draggingIndex, setDraggingIndex] = useState(null); + const { + timelineFullScreen, + setTimelineFullScreen, + globalFullScreen, + setGlobalFullScreen, + } = useFullScreen(); + + const toggleFullScreen = useCallback(() => { + if (timelineId === TimelineId.active) { + setTimelineFullScreen(!timelineFullScreen); + } else { + setGlobalFullScreen(!globalFullScreen); + } + }, [ + timelineId, + setTimelineFullScreen, + timelineFullScreen, + setGlobalFullScreen, + globalFullScreen, + ]); const handleSelectAllChange = useCallback( (event: React.ChangeEvent) => { @@ -165,6 +203,11 @@ export const ColumnHeadersComponent = ({ ] ); + const fullScreen = useMemo( + () => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }), + [globalFullScreen, timelineId, timelineFullScreen] + ); + return ( @@ -206,6 +249,25 @@ export const ColumnHeadersComponent = ({ /> + + + + + + + + {showEventsSelect && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts index becdece2c7612..1ebfa957b654f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/translations.ts @@ -18,6 +18,10 @@ export const FIELD = i18n.translate('xpack.securitySolution.timeline.fieldToolti defaultMessage: 'Field', }); +export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullScreenButton', { + defaultMessage: 'Full screen', +}); + export const TYPE = i18n.translate('xpack.securitySolution.timeline.typeTooltip', { defaultMessage: 'Type', }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts index 6b6ae3c3467b5..576dedfc28b1b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/constants.ts @@ -8,12 +8,12 @@ export const MINIMUM_ACTIONS_COLUMN_WIDTH = 50; // px; /** The (fixed) width of the Actions column */ -export const DEFAULT_ACTIONS_COLUMN_WIDTH = 76; // px; +export const DEFAULT_ACTIONS_COLUMN_WIDTH = 24 * 4; // px; /** * The (fixed) width of the Actions column when the timeline body is used as * an events viewer, which has fewer actions than a regular events viewer */ -export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 26; // px; +export const EVENTS_VIEWER_ACTIONS_COLUMN_WIDTH = 24 * 3; // px; /** Additional column width to include when checkboxes are shown **/ export const SHOW_CHECK_BOXES_COLUMN_WIDTH = 24; // px; /** The default minimum width of a column (when a width for the column type is not specified) */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx index 07ef165a6d911..28a4bf6d8ac51 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/data_driven_columns/index.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { mockTimelineData } from '../../../../../common/mock'; import { defaultHeaders } from '../column_headers/default_headers'; import { columnRenderers } from '../renderers'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index 344fbb59bbe57..3236482e6bc27 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -248,6 +248,7 @@ const StatefulEventComponent: React.FC = ({ event={detailsData || emptyDetails} forceExpand={!!expanded[event._id] && !loading} id={event._id} + onEventToggled={onToggleExpanded} onUpdateColumns={onUpdateColumns} timelineId={timelineId} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index 317f1ed20119b..067cea175c99b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -128,3 +128,38 @@ export const getInvestigateInResolverAction = ({ dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: eventId })), width: DEFAULT_ICON_BUTTON_WIDTH, }); + +/** + * The minimum height of a timeline-based events viewer body, as seen in several + * views, e.g. `Detections`, `Events`, `External events`, etc + */ +export const MIN_EVENTS_VIEWER_BODY_HEIGHT = 500; // px + +interface GetEventsViewerBodyHeightParams { + /** the height of the header, e.g. the section containing "`Showing n event / alerts`, and `Open` / `In progress` / `Closed` filters" */ + headerHeight: number; + /** the height of the footer, e.g. "`25 of 100 events / alerts`, `Load More`, `Updated n minutes ago`" */ + footerHeight: number; + /** the height of the global Kibana chrome, common throughout the app */ + kibanaChromeHeight: number; + /** the (combined) height of other non-events viewer content, e.g. the global search / filter bar in full screen mode */ + otherContentHeight: number; + /** the full height of the window */ + windowHeight: number; +} + +export const getEventsViewerBodyHeight = ({ + footerHeight, + headerHeight, + kibanaChromeHeight, + otherContentHeight, + windowHeight, +}: GetEventsViewerBodyHeightParams) => { + if (windowHeight === 0 || !isFinite(windowHeight)) { + return MIN_EVENTS_VIEWER_BODY_HEIGHT; + } + + const combinedHeights = kibanaChromeHeight + otherContentHeight + headerHeight + footerHeight; + + return Math.max(MIN_EVENTS_VIEWER_BODY_HEIGHT, windowHeight - combinedHeights); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 2df6a39f1a3df..b36f1dcc03261 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -7,6 +7,7 @@ import { ReactWrapper } from '@elastic/eui/node_modules/@types/enzyme'; import React from 'react'; import { useSelector } from 'react-redux'; +import '../../../../common/mock/match_media'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { Direction } from '../../../../graphql/types'; import { defaultHeaders, mockTimelineData, mockTimelineModel } from '../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx index 83e44b77802b7..e971dc6c8e1e2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.tsx @@ -29,11 +29,9 @@ import { Events } from './events'; import { ColumnRenderer } from './renderers/column_renderer'; import { RowRenderer } from './renderers/row_renderer'; import { Sort } from './sort'; -import { useManageTimeline } from '../../manage_timeline'; import { GraphOverlay } from '../../graph_overlay'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../helpers'; -import { TimelineRowAction } from './actions'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineId, TimelineType } from '../../../../../common/types/timeline'; export interface BodyProps { addNoteToEvent: AddNoteToEvent; @@ -70,6 +68,11 @@ export interface BodyProps { updateNote: UpdateNote; } +export const hasAdditonalActions = (id: string): boolean => + id === TimelineId.detectionsPage || id === TimelineId.detectionsRulesDetailsPage; + +const EXTRA_WIDTH = 4; // px + /** Renders the timeline body */ export const Body = React.memo( ({ @@ -107,39 +110,14 @@ export const Body = React.memo( updateNote, }) => { const containerElementRef = useRef(null); - const { getManageTimelineById } = useManageTimeline(); - const timelineActions = useMemo( - () => - data.reduce((acc: TimelineRowAction[], rowData) => { - const rowActions = getManageTimelineById(id).timelineRowActions({ - ecsData: rowData.ecs, - nonEcsData: rowData.data, - }); - return rowActions && - rowActions.filter((v) => v.displayType === 'icon').length > - acc.filter((v) => v.displayType === 'icon').length - ? rowActions - : acc; - }, []), - [data, getManageTimelineById, id] - ); - - const additionalActionWidth = useMemo(() => { - let hasContextMenu = false; - return ( - timelineActions.reduce((acc, v) => { - if (v.displayType === 'icon') { - return acc + (v.width ?? 0); - } - const addWidth = hasContextMenu ? 0 : DEFAULT_ICON_BUTTON_WIDTH; - hasContextMenu = true; - return acc + addWidth; - }, 0) ?? 0 - ); - }, [timelineActions]); const actionsColumnWidth = useMemo( - () => getActionsColumnWidth(isEventViewer, showCheckboxes, additionalActionWidth), - [isEventViewer, showCheckboxes, additionalActionWidth] + () => + getActionsColumnWidth( + isEventViewer, + showCheckboxes, + hasAdditonalActions(id) ? DEFAULT_ICON_BUTTON_WIDTH + EXTRA_WIDTH : 0 + ), + [isEventViewer, showCheckboxes, id] ); const columnWidths = useMemo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx index e7e7d1d47f478..d1e8c8aacca47 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/args.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { TestProviders } from '../../../../../common/mock'; import { ArgsComponent } from './args'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx index b4c95d383593a..726273bc90ad8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_details.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx index 0990280879a14..750fbc0014464 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/generic_file_details.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx index 41e35427ae254..54af8c89b15d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/primary_secondary_user_info.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { PrimarySecondaryUserInfo, nilOrUnSet } from './primary_secondary_user_info'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx index d1e67c25bd79c..ef3e2f72d0473 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/auditd/session_user_host_working_dir.test.tsx @@ -8,6 +8,7 @@ import { EuiFlexItem } from '@elastic/eui'; import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { SessionUserHostWorkingDir } from './session_user_host_working_dir'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx index 0160c62ea40ac..4a0eff1ecf1b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/bytes/index.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { PreferenceFormattedBytes } from '../../../../../../common/components/formatted_bytes'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx index ba77709459c28..e2dff4e13b80d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockEndgameDnsRequest } from '../../../../../../common/mock/mock_endgame_ecs_data'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx index 1d46e4c3eb02d..de3eb01612b2a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/dns/dns_request_event_details_line.test.tsx @@ -12,7 +12,7 @@ import React from 'react'; import { TestProviders } from '../../../../../../common/mock'; - +import '../../../../../../common/mock/match_media'; import { DnsRequestEventDetailsLine } from './dns_request_event_details_line'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx index 1c7eaef893651..6c9dd5092e7c1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/empty_column_renderer.test.tsx @@ -10,6 +10,7 @@ import React from 'react'; import { TimelineNonEcsData } from '../../../../../graphql/types'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../../common/mock'; +import '../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { getEmptyValue } from '../../../../../common/components/empty_value'; import { deleteItemIdx, findItem } from './helpers'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx index e84cb93b87178..47064fa02458a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx index b2b4b021e5db5..6d4b2b518b582 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/endgame/endgame_security_event_details_line.test.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { TestProviders } from '../../../../../../common/mock'; +import '../../../../../../common/mock/match_media'; import { EndgameSecurityEventDetailsLine } from './endgame_security_event_details_line'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx index 4471c26ef8fd7..98a706d5836a0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/exit_code_draggable.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { TestProviders } from '../../../../../common/mock'; +import '../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { ExitCodeDraggable } from './exit_code_draggable'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx index 70e0e74675cd2..a038ceab15b44 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/file_draggable.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../common/mock'; import { FileDraggable } from './file_draggable'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx index 3e055682d27a4..867cf42146485 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/formatted_field.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { get } from 'lodash/fp'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { mockTimelineData, TestProviders } from '../../../../../common/mock'; import { getEmptyValue } from '../../../../../common/components/empty_value'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx index 12b093bd517c8..d1ed5e86e72e5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_column_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { TimelineNonEcsData } from '../../../../../graphql/types'; import { mockTimelineData } from '../../../../../common/mock'; import { TestProviders } from '../../../../../common/mock/test_providers'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx index 0b3ea0ce6e430..0c7fbd08ba98c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/get_row_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { mockBrowserFields } from '../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../graphql/types'; import { mockTimelineData } from '../../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx index 85a000bbcaf63..2dadbabd0ae16 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/host_working_dir.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { mockTimelineData, TestProviders } from '../../../../../common/mock'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { HostWorkingDir } from './host_working_dir'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx index 5140b9abc60ef..8a8b40198bdba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/netflow/netflow_row_renderer.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../graphql/types'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx index 0a173f766ae19..86d39da478c6d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/parent_process_draggable.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../common/mock'; import { ParentProcessDraggable } from './parent_process_draggable'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx index b7c2cb7032cc2..9199278c57f7a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/plain_column_renderer.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import '../../../../../common/mock/match_media'; import { TimelineNonEcsData } from '../../../../../graphql/types'; import { defaultHeaders, mockTimelineData, TestProviders } from '../../../../../common/mock'; import { getEmptyValue } from '../../../../../common/components/empty_value'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx index 91ae94940f7f4..7a7715c86b5c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_draggable.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../../common/mock'; +import '../../../../../common/mock/match_media'; import { ProcessDraggable, ProcessDraggableWithNonExistentProcess } from './process_draggable'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx index 55cc61edb064e..e46a5abc6a9fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/process_hash.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { TestProviders } from '../../../../../common/mock'; +import '../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; import { ProcessHash } from './process_hash'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx index 14f147c61fca3..3b9752224e2c1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_details.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData } from '../../../../../../common/mock'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock/test_providers'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { SuricataDetails } from './suricata_details'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx index d36d24f41224c..7d700732a6409 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_row_renderer.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../graphql/types'; import { mockTimelineData } from '../../../../../../common/mock'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock/test_providers'; import { suricataRowRenderer } from './suricata_row_renderer'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx index a0cad2b059a4b..61e1a28cc7d7d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/suricata/suricata_signature.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.test.tsx index 4e4e1a0b7bf6f..791ae8aadc69c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/auth_ssh.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { AuthSsh } from './auth_ssh'; describe('AuthSsh', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx index 8efd8e1944331..2f2fe2606d132 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_details.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx index 6c7a74d840d01..52c232f377f79 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { BrowserFields } from '../../../../../../common/containers/source'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; +import '../../../../../../common/mock/match_media'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { SystemGenericFileDetails, SystemGenericFileLine } from './generic_file_details'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx index 56f9452ba40b8..36b69790726e9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/package.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { Package } from './package'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx index 7f460d30d709c..d09837e344d7b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/user_host_working_dir.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../../../../common/mock'; +import '../../../../../common/mock/match_media'; import { UserHostWorkingDir } from './user_host_working_dir'; import { useMountAppended } from '../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx index 04b0e6e5fcfae..434be7b23aeee 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_details.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; +import '../../../../../../common/mock/match_media'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx index 2eed6aaf20335..23c38f83b89d4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_row_renderer.test.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { mockBrowserFields } from '../../../../../../common/containers/source/mock'; import { Ecs } from '../../../../../../graphql/types'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; +import '../../../../../../common/mock/match_media'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; import { zeekRowRenderer } from './zeek_row_renderer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx index a0c5b3a8e8c65..3b1ce431bfc87 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/zeek/zeek_signature.test.tsx @@ -8,6 +8,7 @@ import { shallow } from 'enzyme'; import { cloneDeep } from 'lodash/fp'; import React from 'react'; +import '../../../../../../common/mock/match_media'; import { Ecs } from '../../../../../../graphql/types'; import { mockTimelineData, TestProviders } from '../../../../../../common/mock'; import { useMountAppended } from '../../../../../../common/utils/use_mount_appended'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap index 5674c18010f67..ebe6bfcbc2e9a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/__snapshots__/sort_indicator.test.tsx.snap @@ -1,8 +1,15 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SortIndicator rendering renders correctly against snapshot 1`] = ` - + + + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx index 1467813eaf154..dcaedb90e7252 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.test.tsx @@ -8,6 +8,7 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; import { Direction } from '../../../../../graphql/types'; +import * as i18n from '../translations'; import { getDirection, SortIndicator } from './sort_indicator'; @@ -18,13 +19,29 @@ describe('SortIndicator', () => { expect(wrapper).toMatchSnapshot(); }); - test('it renders the sort indicator', () => { + test('it renders the expected sort indicator when direction is ascending', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( + 'sortUp' + ); + }); + + test('it renders the expected sort indicator when direction is descending', () => { const wrapper = mount(); expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( 'sortDown' ); }); + + test('it renders the expected sort indicator when direction is `none`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sortIndicator"]').first().prop('type')).toEqual( + 'empty' + ); + }); }); describe('getDirection', () => { @@ -40,4 +57,28 @@ describe('SortIndicator', () => { expect(getDirection('none')).toEqual(undefined); }); }); + + describe('sort indicator tooltip', () => { + test('it returns the expected tooltip when the direction is ascending', () => { + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content + ).toEqual(i18n.SORTED_ASCENDING); + }); + + test('it returns the expected tooltip when the direction is descending', () => { + const wrapper = mount(); + + expect( + wrapper.find('[data-test-subj="sort-indicator-tooltip"]').first().props().content + ).toEqual(i18n.SORTED_DESCENDING); + }); + + test('it does NOT render a tooltip when sort direction is `none`', () => { + const wrapper = mount(); + + expect(wrapper.find('[data-test-subj="sort-indicator-tooltip"]').exists()).toBe(false); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx index c148e2f6c6295..8b842dfa2197e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/sort/sort_indicator.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon } from '@elastic/eui'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; import React from 'react'; import { Direction } from '../../../../../graphql/types'; +import * as i18n from '../translations'; import { SortDirection } from '.'; @@ -37,8 +38,25 @@ interface Props { } /** Renders a sort indicator */ -export const SortIndicator = React.memo(({ sortDirection }) => ( - -)); +export const SortIndicator = React.memo(({ sortDirection }) => { + const direction = getDirection(sortDirection); + + if (direction != null) { + return ( + + + + ); + } else { + return ; + } +}); SortIndicator.displayName = 'SortIndicator'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index 20467af290b19..c57002023b79d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -45,6 +45,20 @@ export const PINNED_WITH_NOTES = i18n.translate( } ); +export const SORTED_ASCENDING = i18n.translate( + 'xpack.securitySolution.timeline.body.sort.sortedAscendingTooltip', + { + defaultMessage: 'Sorted ascending', + } +); + +export const SORTED_DESCENDING = i18n.translate( + 'xpack.securitySolution.timeline.body.sort.sortedDescendingTooltip', + { + defaultMessage: 'Sorted descending', + } +); + export const DISABLE_PIN = i18n.translate( 'xpack.securitySolution.timeline.body.pinning.disablePinnnedTooltip', { @@ -66,6 +80,13 @@ export const COLLAPSE = i18n.translate( } ); +export const COLLAPSE_EVENT = i18n.translate( + 'xpack.securitySolution.timeline.body.actions.collapseEventTooltip', + { + defaultMessage: 'Collapse event', + } +); + export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate( 'xpack.securitySolution.timeline.body.actions.investigateInResolverTooltip', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx index b08c6afcaf4a6..269cd14b5973c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/expandable_event/index.tsx @@ -34,6 +34,7 @@ interface Props { event: DetailItem[]; forceExpand?: boolean; hideExpandButton?: boolean; + onEventToggled: () => void; onUpdateColumns: OnUpdateColumns; timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; @@ -48,6 +49,7 @@ export const ExpandableEvent = React.memo( id, timelineId, toggleColumn, + onEventToggled, onUpdateColumns, }) => ( @@ -59,6 +61,7 @@ export const ExpandableEvent = React.memo( columnHeaders={columnHeaders} data={event} id={id} + onEventToggled={onEventToggled} onUpdateColumns={onUpdateColumns} timelineId={timelineId} toggleColumn={toggleColumn} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx index ce96e4e50dea0..8b75f8b398ac1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.test.tsx @@ -10,6 +10,7 @@ import { MockedProvider } from 'react-apollo/test-utils'; import { act } from 'react-dom/test-utils'; import useResizeObserver from 'use-resize-observer/polyfilled'; +import '../../../common/mock/match_media'; import { useSignalIndex, ReturnSignalIndex, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index ce99304c676ee..efb19275336db 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -16,6 +16,7 @@ import { TestProviders, kibanaObservable, } from '../../../../common/mock'; +import '../../../../common/mock/match_media'; import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Properties, showDescriptionThreshold, showNotesThreshold } from '.'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx index 68a3362b721d8..8f548f16cf1d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.test.tsx @@ -3,10 +3,12 @@ * 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 { renderHook, act } from '@testing-library/react-hooks'; import { shallow } from 'enzyme'; import { TimelineType } from '../../../../../common/types/timeline'; +import { TestProviders } from '../../../../common/mock'; import { useCreateTimelineButton } from './use_create_timeline'; jest.mock('react-redux', () => { @@ -20,11 +22,15 @@ jest.mock('react-redux', () => { describe('useCreateTimelineButton', () => { const mockId = 'mockId'; const timelineType = TimelineType.default; + const wrapperContainer: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( + {children} + ); test('return getButton', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCreateTimelineButton({ timelineId: mockId, timelineType }) + const { result, waitForNextUpdate } = renderHook( + () => useCreateTimelineButton({ timelineId: mockId, timelineType }), + { wrapper: wrapperContainer } ); await waitForNextUpdate(); @@ -34,8 +40,9 @@ describe('useCreateTimelineButton', () => { test('getButton renders correct outline - EuiButton', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCreateTimelineButton({ timelineId: mockId, timelineType }) + const { result, waitForNextUpdate } = renderHook( + () => useCreateTimelineButton({ timelineId: mockId, timelineType }), + { wrapper: wrapperContainer } ); await waitForNextUpdate(); @@ -47,8 +54,9 @@ describe('useCreateTimelineButton', () => { test('getButton renders correct outline - EuiButtonEmpty', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - useCreateTimelineButton({ timelineId: mockId, timelineType }) + const { result, waitForNextUpdate } = renderHook( + () => useCreateTimelineButton({ timelineId: mockId, timelineType }), + { wrapper: wrapperContainer } ); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index fb05b056cdf82..f418491ac4e47 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -8,7 +8,12 @@ import { useDispatch } from 'react-redux'; import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { timelineActions } from '../../../store/timeline'; -import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; +import { useFullScreen } from '../../../../common/containers/use_full_screen'; +import { + TimelineId, + TimelineType, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; export const useCreateTimelineButton = ({ timelineId, @@ -20,9 +25,14 @@ export const useCreateTimelineButton = ({ closeGearMenu?: () => void; }) => { const dispatch = useDispatch(); + const { timelineFullScreen, setTimelineFullScreen } = useFullScreen(); const createTimeline = useCallback( - ({ id, show }) => + ({ id, show }) => { + if (id === TimelineId.active && timelineFullScreen) { + setTimelineFullScreen(false); + } + dispatch( timelineActions.createTimeline({ id, @@ -30,8 +40,9 @@ export const useCreateTimelineButton = ({ show, timelineType, }) - ), - [dispatch, timelineType] + ); + }, + [dispatch, setTimelineFullScreen, timelineFullScreen, timelineType] ); const handleButtonClick = useCallback(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 58c46af5606f4..555b22eff0c91 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -13,6 +13,7 @@ import { timelineQuery } from '../../containers/index.gql_query'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { Direction } from '../../../graphql/types'; import { defaultHeaders, mockTimelineData, mockIndexPattern } from '../../../common/mock'; +import '../../../common/mock/match_media'; import { TestProviders } from '../../../common/mock/test_providers'; import { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index bd1fac9b05474..1e0e85d4a48d9 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; +import '../../../common/mock/match_media'; import { mockGlobalState, SUB_PLUGINS_REDUCER, From 3c9fa99d685b75150f1c6012fd27ab5eac50a5ba Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 15 Jul 2020 07:26:24 -0400 Subject: [PATCH 02/52] [Security Solution][Detection Engine] - Update exceptions logic (#71512) Co-authored-by: Elastic Machine Co-authored-by: Yara Tercero --- .../scripts/lists/new/items/ip_item.json | 2 +- .../scripts/lists/new/items/keyword_item.json | 2 +- .../build_exceptions_query.test.ts | 976 +++++------------- .../build_exceptions_query.ts | 118 +-- .../detection_engine/get_query_filter.test.ts | 130 +-- .../detection_engine/get_query_filter.ts | 16 +- .../common/detection_engine/utils.test.ts | 105 ++ .../common/detection_engine/utils.ts | 17 + .../signals/filter_events_with_list.ts | 20 +- .../signals/get_filter.test.ts | 85 +- .../signals/single_search_after.ts | 1 + .../detection_engine/signals/utils.test.ts | 49 - .../lib/detection_engine/signals/utils.ts | 8 +- 13 files changed, 562 insertions(+), 967 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/utils.test.ts create mode 100644 x-pack/plugins/security_solution/common/detection_engine/utils.ts diff --git a/x-pack/plugins/lists/server/scripts/lists/new/items/ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/items/ip_item.json index 563139c40c0ca..c2238890496bb 100644 --- a/x-pack/plugins/lists/server/scripts/lists/new/items/ip_item.json +++ b/x-pack/plugins/lists/server/scripts/lists/new/items/ip_item.json @@ -1,5 +1,5 @@ { "id": "ip_item", "list_id": "ip_list", - "value": "10.4.2.140" + "value": "127.0.0.1" } diff --git a/x-pack/plugins/lists/server/scripts/lists/new/items/keyword_item.json b/x-pack/plugins/lists/server/scripts/lists/new/items/keyword_item.json index 96d925c157490..0848dc4c1bd94 100644 --- a/x-pack/plugins/lists/server/scripts/lists/new/items/keyword_item.json +++ b/x-pack/plugins/lists/server/scripts/lists/new/items/keyword_item.json @@ -1,4 +1,4 @@ { "list_id": "keyword_list", - "value": "kibana" + "value": "zeek" } diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index 26a219507c3ae..caf2dfb761ed0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -113,226 +113,97 @@ describe('build_exceptions_query', () => { }); describe('operatorBuilder', () => { - describe("when 'exclude' is true", () => { - describe('and langauge is kuery', () => { - test('it returns "not " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); - expect(operator).toEqual('not '); - }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); - expect(operator).toEqual(''); - }); + describe('and language is kuery', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); + expect(operator).toEqual(''); }); - - describe('and language is lucene', () => { - test('it returns "NOT " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); - expect(operator).toEqual('NOT '); - }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); - expect(operator).toEqual(''); - }); + test('it returns "not " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); + expect(operator).toEqual('not '); }); }); - describe("when 'exclude' is false", () => { - beforeEach(() => { - exclude = false; - }); - describe('and language is kuery', () => { - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); - expect(operator).toEqual(''); - }); - test('it returns "not " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); - expect(operator).toEqual('not '); - }); + describe('and language is lucene', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); + expect(operator).toEqual(''); }); - - describe('and language is lucene', () => { - test('it returns empty string when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); - expect(operator).toEqual(''); - }); - test('it returns "NOT " when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); - expect(operator).toEqual('NOT '); - }); + test('it returns "NOT " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); + expect(operator).toEqual('NOT '); }); }); }); describe('buildExists', () => { - describe("when 'exclude' is true", () => { - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: existsEntryWithExcluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('host.name:*'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: existsEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('not host.name:*'); + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', }); + expect(query).toEqual('not host.name:*'); }); - - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: existsEntryWithExcluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('_exists_host.name'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: existsEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('NOT _exists_host.name'); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', }); + expect(query).toEqual('host.name:*'); }); }); - describe("when 'exclude' is false", () => { - beforeEach(() => { - exclude = false; - }); - - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: existsEntryWithExcluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('not host.name:*'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: existsEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('host.name:*'); + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', }); + expect(query).toEqual('NOT _exists_host.name'); }); - - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: existsEntryWithExcluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('NOT _exists_host.name'); - }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: existsEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('_exists_host.name'); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', }); + expect(query).toEqual('_exists_host.name'); }); }); }); describe('buildMatch', () => { - describe("when 'exclude' is true", () => { - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: matchEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('not host.name:suricata'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: matchEntryWithExcluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('host.name:suricata'); + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', }); + expect(query).toEqual('host.name:"suricata"'); }); - - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: matchEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('NOT host.name:suricata'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: matchEntryWithExcluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('host.name:suricata'); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', }); + expect(query).toEqual('not host.name:"suricata"'); }); }); - describe("when 'exclude' is false", () => { - beforeEach(() => { - exclude = false; - }); - - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: matchEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('host.name:suricata'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: matchEntryWithExcluded, - language: 'kuery', - exclude, - }); - expect(query).toEqual('not host.name:suricata'); + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', }); + expect(query).toEqual('host.name:"suricata"'); }); - - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: matchEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('host.name:suricata'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: matchEntryWithExcluded, - language: 'lucene', - exclude, - }); - expect(query).toEqual('NOT host.name:suricata'); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', }); + expect(query).toEqual('NOT host.name:"suricata"'); }); }); }); @@ -352,152 +223,83 @@ describe('build_exceptions_query', () => { operator: 'excluded', }); - describe("when 'exclude' is true", () => { - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndNoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual(''); - }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('not host.name:(suricata)'); - }); - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', }); + expect(exceptionSegment).toEqual(''); + }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', }); + + expect(exceptionSegment).toEqual('host.name:("suricata")'); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); - }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); - }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', }); - }); - }); - describe("when 'exclude' is false", () => { - beforeEach(() => { - exclude = false; + expect(exceptionSegment).toEqual('host.name:("suricata" or "auditd")'); }); - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndNoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual(''); - }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata)'); - }); - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); - }); + expect(exceptionSegment).toEqual('not host.name:("suricata" or "auditd")'); }); + }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + + expect(exceptionSegment).toEqual('host.name:("suricata" OR "auditd")'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, - language: 'lucene', - exclude, - }); - expect(exceptionSegment).toEqual('host.name:(suricata)'); + + expect(exceptionSegment).toEqual('NOT host.name:("suricata" OR "auditd")'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', }); + + expect(exceptionSegment).toEqual('host.name:("suricata")'); }); }); }); describe('buildNested', () => { + // NOTE: Only KQL supports nested describe('kuery', () => { test('it returns formatted query when one item in nested entry', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'included' })], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-1 }'); + expect(result).toEqual('parent:{ nestedField:"value-1" }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -505,206 +307,128 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), - makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), + makeMatchEntry({ field: 'nestedField', operator: 'included' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'included', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-1 and nestedFieldB:value-2 }'); - }); - }); - - // TODO: Does lucene support nested query syntax? - describe.skip('lucene', () => { - test('it returns formatted query when one item in nested entry', () => { - const item: EntryNested = { - field: 'parent', - type: 'nested', - entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], - }; - const result = buildNested({ item, language: 'lucene' }); - - expect(result).toEqual('parent:{ nestedField:value-1 }'); - }); - - test('it returns formatted query when multiple items in nested entry', () => { - const item: EntryNested = { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), - makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), - ], - }; - const result = buildNested({ item, language: 'lucene' }); - - expect(result).toEqual('parent:{ nestedField:value-1 AND nestedFieldB:value-2 }'); + expect(result).toEqual('parent:{ nestedField:"value-1" and nestedFieldB:"value-2" }'); }); }); }); describe('evaluateValues', () => { - describe("when 'exclude' is true", () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = evaluateValues({ - item: existsEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(result).toEqual('not host.name:*'); - }); - test('it returns formatted string when "type" is "match"', () => { - const result = evaluateValues({ - item: matchEntryWithIncluded, - language: 'kuery', - exclude, - }); - expect(result).toEqual('not host.name:suricata'); - }); - test('it returns formatted string when "type" is "match_any"', () => { - const result = evaluateValues({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(result).toEqual('not host.name:(suricata or auditd)'); + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'kuery', }); + expect(result).toEqual('host.name:*'); }); - describe('lucene', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = evaluateValues({ - item: existsEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(result).toEqual('NOT _exists_host.name'); - }); - test('it returns formatted string when "type" is "match"', () => { - const result = evaluateValues({ - item: matchEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(result).toEqual('NOT host.name:suricata'); - }); - test('it returns formatted string when "type" is "match_any"', () => { - const result = evaluateValues({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(result).toEqual('NOT host.name:(suricata OR auditd)'); - }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'kuery', }); + expect(result).toEqual('host.name:"suricata"'); }); - }); - describe("when 'exclude' is false", () => { - beforeEach(() => { - exclude = false; + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + }); + expect(result).toEqual('host.name:("suricata" or "auditd")'); }); + }); + describe('lucene', () => { describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { const result = evaluateValues({ item: existsEntryWithIncluded, - language: 'kuery', - exclude, + language: 'lucene', }); - expect(result).toEqual('host.name:*'); + expect(result).toEqual('_exists_host.name'); }); + test('it returns formatted string when "type" is "match"', () => { const result = evaluateValues({ item: matchEntryWithIncluded, - language: 'kuery', - exclude, + language: 'lucene', }); - expect(result).toEqual('host.name:suricata'); + expect(result).toEqual('host.name:"suricata"'); }); + test('it returns formatted string when "type" is "match_any"', () => { const result = evaluateValues({ item: matchAnyEntryWithIncludedAndTwoValues, - language: 'kuery', - exclude, - }); - expect(result).toEqual('host.name:(suricata or auditd)'); - }); - }); - - describe('lucene', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = evaluateValues({ - item: existsEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(result).toEqual('_exists_host.name'); - }); - test('it returns formatted string when "type" is "match"', () => { - const result = evaluateValues({ - item: matchEntryWithIncluded, - language: 'lucene', - exclude, - }); - expect(result).toEqual('host.name:suricata'); - }); - test('it returns formatted string when "type" is "match_any"', () => { - const result = evaluateValues({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'lucene', - exclude, - }); - expect(result).toEqual('host.name:(suricata OR auditd)'); + language: 'lucene', }); + expect(result).toEqual('host.name:("suricata" OR "auditd")'); }); }); }); }); describe('formatQuery', () => { - describe('when query is empty string', () => { - test('it returns query if "exceptions" is empty array', () => { - const formattedQuery = formatQuery({ exceptions: [], query: '', language: 'kuery' }); - expect(formattedQuery).toEqual(''); + describe('exclude is true', () => { + describe('when query is empty string', () => { + test('it returns empty string if "exceptions" is empty array', () => { + const formattedQuery = formatQuery({ exceptions: [], language: 'kuery', exclude: true }); + expect(formattedQuery).toEqual(''); + }); + + test('it returns expected query string when single exception in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:("value-1" or "value-2") and not c:*'], + language: 'kuery', + exclude: true, + }); + expect(formattedQuery).toEqual('not ((b:("value-1" or "value-2") and not c:*))'); + }); }); - test('it returns expected query string when single exception in array', () => { + + test('it returns expected query string when multiple exceptions in array', () => { const formattedQuery = formatQuery({ - exceptions: ['b:(value-1 or value-2) and not c:*'], - query: '', + exceptions: ['b:("value-1" or "value-2") and not c:*', 'not d:*'], language: 'kuery', + exclude: true, }); - expect(formattedQuery).toEqual('(b:(value-1 or value-2) and not c:*)'); + expect(formattedQuery).toEqual( + 'not ((b:("value-1" or "value-2") and not c:*) or (not d:*))' + ); }); }); - test('it returns query if "exceptions" is empty array', () => { - const formattedQuery = formatQuery({ exceptions: [], query: 'a:*', language: 'kuery' }); - expect(formattedQuery).toEqual('a:*'); - }); + describe('exclude is false', () => { + describe('when query is empty string', () => { + test('it returns empty string if "exceptions" is empty array', () => { + const formattedQuery = formatQuery({ exceptions: [], language: 'kuery', exclude: false }); + expect(formattedQuery).toEqual(''); + }); - test('it returns expected query string when single exception in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:(value-1 or value-2) and not c:*'], - query: 'a:*', - language: 'kuery', + test('it returns expected query string when single exception in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:("value-1" or "value-2") and not c:*'], + language: 'kuery', + exclude: false, + }); + expect(formattedQuery).toEqual('(b:("value-1" or "value-2") and not c:*)'); + }); }); - expect(formattedQuery).toEqual('(a:* and b:(value-1 or value-2) and not c:*)'); - }); - test('it returns expected query string when multiple exceptions in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:(value-1 or value-2) and not c:*', 'not d:*'], - query: 'a:*', - language: 'kuery', + test('it returns expected query string when multiple exceptions in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:("value-1" or "value-2") and not c:*', 'not d:*'], + language: 'kuery', + exclude: false, + }); + expect(formattedQuery).toEqual('(b:("value-1" or "value-2") and not c:*) or (not d:*)'); }); - expect(formattedQuery).toEqual( - '(a:* and b:(value-1 or value-2) and not c:*) or (a:* and not d:*)' - ); }); }); @@ -712,81 +436,69 @@ describe('build_exceptions_query', () => { test('it returns empty string if empty lists array passed in', () => { const query = buildExceptionItemEntries({ language: 'kuery', - lists: [], - exclude, + entries: [], }); expect(query).toEqual(''); }); - test('it returns expected query when more than one item in list', () => { - // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) - // https://www.dcode.fr/boolean-expressions-calculator + test('it returns expected query when more than one item in exception item', () => { const payload: EntriesArray = [ makeMatchAnyEntry({ field: 'b' }), makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists: payload, - exclude, + entries: payload, }); - const expectedQuery = 'not b:(value-1 or value-2) and c:value-3'; + const expectedQuery = 'b:("value-1" or "value-2") and not c:"value-3"'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list item includes nested value', () => { - // Equal to query && !(b || !c) -> (query AND NOT b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + test('it returns expected query when exception item includes nested value', () => { + const entries: EntriesArray = [ makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + makeMatchEntry({ field: 'nestedField', operator: 'included', value: 'value-3' }), ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; + const expectedQuery = 'b:("value-1" or "value-2") and parent:{ nestedField:"value-3" }'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes multiple items and nested "and" values', () => { - // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + test('it returns expected query when exception item includes multiple items and nested "and" values', () => { + const entries: EntriesArray = [ makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + makeMatchEntry({ field: 'nestedField', operator: 'included', value: 'value-3' }), ], }, makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); const expectedQuery = - 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*'; + 'b:("value-1" or "value-2") and parent:{ nestedField:"value-3" } and d:*'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when language is "lucene"', () => { - // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchAnyEntry({ field: 'b' }), { field: 'parent', @@ -799,170 +511,56 @@ describe('build_exceptions_query', () => { ]; const query = buildExceptionItemEntries({ language: 'lucene', - lists, - exclude, + entries, }); const expectedQuery = - 'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e'; + 'b:("value-1" OR "value-2") AND parent:{ nestedField:"value-3" } AND NOT _exists_e'; expect(query).toEqual(expectedQuery); }); - describe('when "exclude" is false', () => { - beforeEach(() => { - exclude = false; - }); - - test('it returns empty string if empty lists array passed in', () => { - const query = buildExceptionItemEntries({ - language: 'kuery', - lists: [], - exclude, - }); - - expect(query).toEqual(''); - }); - test('it returns expected query when more than one item in list', () => { - // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const payload: EntriesArray = [ - makeMatchAnyEntry({ field: 'b' }), - makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), - ]; - const query = buildExceptionItemEntries({ - language: 'kuery', - lists: payload, - exclude, - }); - const expectedQuery = 'b:(value-1 or value-2) and not c:value-3'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list item includes nested value', () => { - // Equal to query && !(b || !c) -> (query AND NOT b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), - ], - }, - ]; - const query = buildExceptionItemEntries({ - language: 'kuery', - lists, - exclude, - }); - const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; - - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when list includes multiple items and nested "and" values', () => { - // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), - ], - }, - makeExistsEntry({ field: 'd' }), - ]; - const query = buildExceptionItemEntries({ - language: 'kuery', - lists, - exclude, - }); - const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 } and d:*'; - expect(query).toEqual(expectedQuery); - }); - - test('it returns expected query when language is "lucene"', () => { - // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), - ], - }, - makeExistsEntry({ field: 'e', operator: 'excluded' }), - ]; - const query = buildExceptionItemEntries({ - language: 'lucene', - lists, - exclude, - }); - const expectedQuery = - 'b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND NOT _exists_e'; - expect(query).toEqual(expectedQuery); - }); - }); - describe('exists', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [makeExistsEntry({ field: 'b' })]; + const entries: EntriesArray = [makeExistsEntry({ field: 'b' })]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:*'; + const expectedQuery = 'b:*'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; + const entries: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:*'; + const expectedQuery = 'not b:*'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes list item with "and" values', () => { - // Equal to query && !(!b || !c) -> (query AND b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + test('it returns expected query when exception item includes entry item with "and" values', () => { + const entries: EntriesArray = [ makeExistsEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' })], + entries: [makeMatchEntry({ field: 'c', operator: 'included', value: 'value-1' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:* and parent:{ c:value-1 }'; + const expectedQuery = 'not b:* and parent:{ c:"value-1" }'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes multiple items', () => { - // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeExistsEntry({ field: 'b' }), { field: 'parent', @@ -976,10 +574,9 @@ describe('build_exceptions_query', () => { ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*'; + const expectedQuery = 'b:* and parent:{ c:"value-1" and d:"value-2" } and e:*'; expect(query).toEqual(expectedQuery); }); @@ -987,60 +584,49 @@ describe('build_exceptions_query', () => { describe('match', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; + const entries: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:value'; + const expectedQuery = 'b:"value"'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:value'; + const expectedQuery = 'not b:"value"'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes list item with "and" values', () => { - // Equal to query && !(!b || !c) -> (query AND b AND c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), { field: 'parent', type: 'nested', - entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], + entries: [makeMatchEntry({ field: 'c', operator: 'included', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:value and parent:{ c:valueC }'; + const expectedQuery = 'not b:"value" and parent:{ c:"valueC" }'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes multiple items', () => { - // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchEntry({ field: 'b', value: 'value' }), { field: 'parent', @@ -1054,10 +640,9 @@ describe('build_exceptions_query', () => { ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueD } and not e:valueE'; + const expectedQuery = 'b:"value" and parent:{ c:"valueC" and d:"valueD" } and e:"valueE"'; expect(query).toEqual(expectedQuery); }); @@ -1065,37 +650,29 @@ describe('build_exceptions_query', () => { describe('match_any', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; + const entries: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:(value-1 or value-2)'; + const expectedQuery = 'b:("value-1" or "value-2")'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; + const entries: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:(value-1 or value-2)'; + const expectedQuery = 'not b:("value-1" or "value-2")'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes list item with nested values', () => { - // Equal to query && !(!b || c) -> (query AND b AND NOT c) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchAnyEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', @@ -1105,27 +682,23 @@ describe('build_exceptions_query', () => { ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'b:(value-1 or value-2) and parent:{ c:valueC }'; + const expectedQuery = 'not b:("value-1" or "value-2") and parent:{ c:"valueC" }'; expect(query).toEqual(expectedQuery); }); test('it returns expected query when list includes multiple items', () => { - // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ + const entries: EntriesArray = [ makeMatchAnyEntry({ field: 'b' }), makeMatchAnyEntry({ field: 'c' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', - lists, - exclude, + entries, }); - const expectedQuery = 'not b:(value-1 or value-2) and not c:(value-1 or value-2)'; + const expectedQuery = 'b:("value-1" or "value-2") and c:("value-1" or "value-2")'; expect(query).toEqual(expectedQuery); }); @@ -1133,16 +706,19 @@ describe('build_exceptions_query', () => { }); describe('buildQueryExceptions', () => { - test('it returns original query if lists is empty array', () => { - const query = buildQueryExceptions({ query: 'host.name: *', language: 'kuery', lists: [] }); - const expectedQuery = 'host.name: *'; + test('it returns empty array if lists is empty array', () => { + const query = buildQueryExceptions({ language: 'kuery', lists: [] }); - expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + expect(query).toEqual([]); + }); + + test('it returns empty array if lists is undefined', () => { + const query = buildQueryExceptions({ language: 'kuery', lists: undefined }); + + expect(query).toEqual([]); }); test('it returns expected query when lists exist and language is "kuery"', () => { - // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ @@ -1151,47 +727,33 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), - makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + makeMatchEntry({ field: 'c', operator: 'included', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'included', value: 'valueD' }), ], }, - makeMatchAnyEntry({ field: 'e' }), + makeMatchAnyEntry({ field: 'e', operator: 'excluded' }), ]; const query = buildQueryExceptions({ - query: 'a:*', language: 'kuery', lists: [payload, payload2], }); const expectedQuery = - '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and not e:(value-1 or value-2))'; + 'not ((some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ c:"valueC" and d:"valueD" } and not e:("value-1" or "value-2")))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); test('it returns expected query when lists exist and language is "lucene"', () => { - // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator const payload = getExceptionListItemSchemaMock(); + payload.entries = [makeMatchAnyEntry({ field: 'a' }), makeMatchAnyEntry({ field: 'b' })]; const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), - makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), - ], - }, - makeMatchAnyEntry({ field: 'e' }), - ]; + payload2.entries = [makeMatchAnyEntry({ field: 'c' }), makeMatchAnyEntry({ field: 'd' })]; const query = buildQueryExceptions({ - query: 'a:*', language: 'lucene', lists: [payload, payload2], }); const expectedQuery = - '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND NOT e:(value-1 OR value-2))'; + 'NOT ((a:("value-1" OR "value-2") AND b:("value-1" OR "value-2")) OR (c:("value-1" OR "value-2") AND d:("value-1" OR "value-2")))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); @@ -1201,21 +763,23 @@ describe('build_exceptions_query', () => { exclude = false; }); - test('it returns original query if lists is empty array', () => { + test('it returns empty array if lists is empty array', () => { const query = buildQueryExceptions({ - query: 'host.name: *', language: 'kuery', lists: [], exclude, }); - const expectedQuery = 'host.name: *'; - expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + expect(query).toEqual([]); + }); + + test('it returns empty array if lists is undefined', () => { + const query = buildQueryExceptions({ language: 'kuery', lists: undefined, exclude }); + + expect(query).toEqual([]); }); test('it returns expected query when lists exist and language is "kuery"', () => { - // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ @@ -1231,42 +795,28 @@ describe('build_exceptions_query', () => { makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ - query: 'a:*', language: 'kuery', lists: [payload, payload2], exclude, }); const expectedQuery = - '(a:* and some.parentField:{ nested.field:some value } and some.not.nested.field:some value) or (a:* and b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and e:(value-1 or value-2))'; + '(some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ c:"valueC" and d:"valueD" } and e:("value-1" or "value-2"))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); test('it returns expected query when lists exist and language is "lucene"', () => { - // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) - // https://www.dcode.fr/boolean-expressions-calculator const payload = getExceptionListItemSchemaMock(); + payload.entries = [makeMatchAnyEntry({ field: 'a' }), makeMatchAnyEntry({ field: 'b' })]; const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), - makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), - ], - }, - makeMatchAnyEntry({ field: 'e' }), - ]; + payload2.entries = [makeMatchAnyEntry({ field: 'c' }), makeMatchAnyEntry({ field: 'd' })]; const query = buildQueryExceptions({ - query: 'a:*', language: 'lucene', lists: [payload, payload2], exclude, }); const expectedQuery = - '(a:* AND some.parentField:{ nested.field:some value } AND some.not.nested.field:some value) OR (a:* AND b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND e:(value-1 OR value-2))'; + '(a:("value-1" OR "value-2") AND b:("value-1" OR "value-2")) OR (c:("value-1" OR "value-2") AND d:("value-1" OR "value-2"))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index a70e6a6638589..fc4fbae02b8fb 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -19,7 +19,8 @@ import { ExceptionListItemSchema, CreateExceptionListItemSchema, } from '../shared_imports'; -import { Language, Query } from './schemas/common/schemas'; +import { Language } from './schemas/common/schemas'; +import { hasLargeValueList } from './utils'; type Operators = 'and' | 'or' | 'not'; type LuceneOperators = 'AND' | 'OR' | 'NOT'; @@ -46,18 +47,16 @@ export const getLanguageBooleanOperator = ({ export const operatorBuilder = ({ operator, language, - exclude, }: { operator: Operator; language: Language; - exclude: boolean; }): string => { const not = getLanguageBooleanOperator({ language, value: 'not', }); - if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) { + if (operator === 'excluded') { return `${not} `; } else { return ''; @@ -67,14 +66,12 @@ export const operatorBuilder = ({ export const buildExists = ({ item, language, - exclude, }: { item: EntryExists; language: Language; - exclude: boolean; }): string => { const { operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language, exclude }); + const exceptionOperator = operatorBuilder({ operator, language }); switch (language) { case 'kuery': @@ -89,26 +86,22 @@ export const buildExists = ({ export const buildMatch = ({ item, language, - exclude, }: { item: EntryMatch; language: Language; - exclude: boolean; }): string => { const { value, operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language, exclude }); + const exceptionOperator = operatorBuilder({ operator, language }); - return `${exceptionOperator}${field}:${value}`; + return `${exceptionOperator}${field}:"${value}"`; }; export const buildMatchAny = ({ item, language, - exclude, }: { item: EntryMatchAny; language: Language; - exclude: boolean; }): string => { const { value, operator, field } = item; @@ -117,8 +110,8 @@ export const buildMatchAny = ({ return ''; default: const or = getLanguageBooleanOperator({ language, value: 'or' }); - const exceptionOperator = operatorBuilder({ operator, language, exclude }); - const matchAnyValues = value.map((v) => v); + const exceptionOperator = operatorBuilder({ operator, language }); + const matchAnyValues = value.map((v) => `"${v}"`); return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; } @@ -133,7 +126,7 @@ export const buildNested = ({ }): string => { const { field, entries } = item; const and = getLanguageBooleanOperator({ language, value: 'and' }); - const values = entries.map((entry) => `${entry.field}:${entry.value}`); + const values = entries.map((entry) => `${entry.field}:"${entry.value}"`); return `${field}:{ ${values.join(` ${and} `)} }`; }; @@ -141,18 +134,16 @@ export const buildNested = ({ export const evaluateValues = ({ item, language, - exclude, }: { item: Entry | EntryNested; language: Language; - exclude: boolean; }): string => { if (entriesExists.is(item)) { - return buildExists({ item, language, exclude }); + return buildExists({ item, language }); } else if (entriesMatch.is(item)) { - return buildMatch({ item, language, exclude }); + return buildMatch({ item, language }); } else if (entriesMatchAny.is(item)) { - return buildMatchAny({ item, language, exclude }); + return buildMatchAny({ item, language }); } else if (entriesNested.is(item)) { return buildNested({ item, language }); } else { @@ -162,78 +153,79 @@ export const evaluateValues = ({ export const formatQuery = ({ exceptions, - query, language, + exclude, }: { exceptions: string[]; - query: string; language: Language; + exclude: boolean; }): string => { - if (exceptions.length > 0) { - const or = getLanguageBooleanOperator({ language, value: 'or' }); - const and = getLanguageBooleanOperator({ language, value: 'and' }); - const formattedExceptions = exceptions.map((exception) => { - if (query === '') { - return `(${exception})`; - } else { - return `(${query} ${and} ${exception})`; - } - }); - - return formattedExceptions.join(` ${or} `); - } else { - return query; + if (exceptions == null || (exceptions != null && exceptions.length === 0)) { + return ''; } + + const or = getLanguageBooleanOperator({ language, value: 'or' }); + const not = getLanguageBooleanOperator({ language, value: 'not' }); + const formattedExceptionItems = exceptions.map((exceptionItem, index) => { + if (index === 0) { + return `(${exceptionItem})`; + } + + return `${or} (${exceptionItem})`; + }); + + const exceptionItemsQuery = formattedExceptionItems.join(' '); + return exclude ? `${not} (${exceptionItemsQuery})` : exceptionItemsQuery; }; export const buildExceptionItemEntries = ({ - lists, + entries, language, - exclude, }: { - lists: EntriesArray; + entries: EntriesArray; language: Language; - exclude: boolean; }): string => { const and = getLanguageBooleanOperator({ language, value: 'and' }); - const exceptionItem = lists - .filter(({ type }) => type !== 'list') - .reduce((accum, listItem) => { - const exceptionSegment = evaluateValues({ item: listItem, language, exclude }); - return [...accum, exceptionSegment]; - }, []); - - return exceptionItem.join(` ${and} `); + const exceptionItemEntries = entries.reduce((accum, listItem) => { + const exceptionSegment = evaluateValues({ item: listItem, language }); + return [...accum, exceptionSegment]; + }, []); + + return exceptionItemEntries.join(` ${and} `); }; export const buildQueryExceptions = ({ - query, language, lists, exclude = true, }: { - query: Query; language: Language; lists: Array | undefined; exclude?: boolean; }): DataQuery[] => { - if (lists != null) { - const exceptions = lists.reduce((acc, exceptionItem) => { - return [ - ...acc, - ...(exceptionItem.entries !== undefined - ? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })] - : []), - ]; - }, []); - const formattedQuery = formatQuery({ exceptions, language, query }); + if (lists == null || (lists != null && lists.length === 0)) { + return []; + } + + const exceptionItems = lists.reduce((acc, exceptionItem) => { + const { entries } = exceptionItem; + + if (entries != null && entries.length > 0 && !hasLargeValueList(entries)) { + return [...acc, buildExceptionItemEntries({ entries, language })]; + } else { + return acc; + } + }, []); + + if (exceptionItems.length === 0) { + return []; + } else { + const formattedQuery = formatQuery({ exceptions: exceptionItems, language, exclude }); return [ { query: formattedQuery, language, }, ]; - } else { - return [{ query, language }]; } }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index c19ef45605f83..a8eb4e7bbb15b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -362,62 +362,45 @@ describe('get_filter', () => { expect(esQuery).toEqual({ bool: { filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, { bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.name': 'linux', - }, - }, - ], - }, - }, - { - bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.parentField.nested.field': 'some value', - }, + must_not: { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', }, - ], - }, + }, + ], }, - score_mode: 'none', }, + score_mode: 'none', }, - { - bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.not.nested.field': 'some value', - }, - }, - ], + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', }, }, - }, + ], }, - ], - }, + }, + ], }, - ], + }, }, }, ], @@ -469,52 +452,35 @@ describe('get_filter', () => { expect(esQuery).toEqual({ bool: { filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, { bool: { filter: [ { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.name': 'linux', - }, + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], }, - ], + }, + score_mode: 'none', }, }, { bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.parentField.nested.field': 'some value', - }, - }, - ], - }, - }, - score_mode: 'none', - }, - }, + minimum_should_match: 1, + should: [ { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.not.nested.field': 'some value', - }, - }, - ], + match_phrase: { + 'some.not.nested.field': 'some value', }, }, ], diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index 6584373b806d8..a41589b5d0231 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -31,12 +31,16 @@ export const getQueryFilter = ( title: index.join(), }; - const queries: DataQuery[] = buildQueryExceptions({ - query, - language, - lists, - exclude: excludeExceptions, - }); + const initialQuery = [{ query, language }]; + /* + * Pinning exceptions to 'kuery' because lucene + * does not support nested queries, while our exceptions + * UI does, since we can pass both lucene and kql into + * buildEsQuery, this allows us to offer nested queries + * regardless + */ + const exceptions = buildQueryExceptions({ language: 'kuery', lists, exclude: excludeExceptions }); + const queries: DataQuery[] = [...initialQuery, ...exceptions]; const config = { allowLeadingWildcards: true, diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts new file mode 100644 index 0000000000000..99680ffe41d44 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { hasLargeValueList, hasNestedEntry } from './utils'; +import { EntriesArray } from '../shared_imports'; + +describe('#hasLargeValueList', () => { + test('it returns false if empty array', () => { + const hasLists = hasLargeValueList([]); + + expect(hasLists).toBeFalsy(); + }); + + test('it returns true if item of type EntryList exists', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'list', + operator: 'included', + list: { id: 'some id', type: 'ip' }, + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasLargeValueList(entries); + + expect(hasLists).toBeTruthy(); + }); + + test('it returns false if item of type EntryList does not exist', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: 'included', + value: 'Elastic, N.V.', + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasLargeValueList(entries); + + expect(hasLists).toBeFalsy(); + }); +}); + +describe('#hasNestedEntry', () => { + test('it returns false if empty array', () => { + const hasLists = hasNestedEntry([]); + + expect(hasLists).toBeFalsy(); + }); + + test('it returns true if item of type EntryNested exists', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'nested', + entries: [ + { field: 'some field', type: 'match', operator: 'included', value: 'some value' }, + ], + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasNestedEntry(entries); + + expect(hasLists).toBeTruthy(); + }); + + test('it returns false if item of type EntryNested does not exist', () => { + const entries: EntriesArray = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: 'included', + value: 'Elastic, N.V.', + }, + { + field: 'file.signature.signer', + type: 'match', + operator: 'excluded', + value: 'Global Signer', + }, + ]; + const hasLists = hasNestedEntry(entries); + + expect(hasLists).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts new file mode 100644 index 0000000000000..fa1812235f897 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts @@ -0,0 +1,17 @@ +/* + * 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 { EntriesArray } from '../shared_imports'; + +export const hasLargeValueList = (entries: EntriesArray): boolean => { + const found = entries.filter(({ type }) => type === 'list'); + return found.length > 0; +}; + +export const hasNestedEntry = (entries: EntriesArray): boolean => { + const found = entries.filter(({ type }) => type === 'nested'); + return found.length > 0; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts index 8af08a02f4152..654ace290f85f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -14,6 +14,7 @@ import { EntryList, ExceptionListItemSchema, } from '../../../../../lists/common/schemas'; +import { hasLargeValueList } from '../../../../common/detection_engine/utils'; interface FilterEventsAgainstList { listClient: ListClient; @@ -36,11 +37,28 @@ export const filterEventsAgainstList = async ({ return eventSearchResult; } + const exceptionItemsWithLargeValueLists = exceptionsList.reduce( + (acc, exception) => { + const { entries } = exception; + if (hasLargeValueList(entries)) { + return [...acc, exception]; + } + + return acc; + }, + [] + ); + + if (exceptionItemsWithLargeValueLists.length === 0) { + logger.debug(buildRuleMessage('about to return original search result')); + return eventSearchResult; + } + // narrow unioned type to be single const isStringableType = (val: SearchTypes) => ['string', 'number', 'boolean'].includes(typeof val); // grab the signals with values found in the given exception lists. - const filteredHitsPromises = exceptionsList.map( + const filteredHitsPromises = exceptionItemsWithLargeValueLists.map( async (exceptionItem: ExceptionListItemSchema) => { const { entries } = exceptionItem; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts index f34879781e0b0..a5740d7719f47 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts @@ -192,71 +192,66 @@ describe('get_filter', () => { index: ['auditbeat-*'], lists: [getExceptionListItemSchemaMock()], }); + expect(filter).toEqual({ bool: { + must: [], filter: [ { bool: { - filter: [ + should: [ { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'host.name': 'siem', - }, - }, - ], + match: { + 'host.name': 'siem', }, }, - { - bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.parentField.nested.field': 'some value', - }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + must_not: { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', }, - ], - }, + }, + ], + minimum_should_match: 1, }, - score_mode: 'none', }, + score_mode: 'none', }, - { - bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - match: { - 'some.not.nested.field': 'some value', - }, - }, - ], + }, + { + bool: { + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', }, }, - }, + ], + minimum_should_match: 1, }, - ], - }, + }, + ], }, - ], + }, }, }, ], - must: [], - must_not: [], should: [], + must_not: [], }, }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 5667f2e47b6d7..92ce7a2836115 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -52,6 +52,7 @@ export const singleSearchAfter = async ({ searchAfterSortId, timestampOverride, }); + const start = performance.now(); const nextSearchAfterResult: SignalSearchResponse = await services.callCluster( 'search', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index a6130a20f9c52..a610970907bf8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -9,7 +9,6 @@ import sinon from 'sinon'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { listMock } from '../../../../../lists/server/mocks'; -import { EntriesArray } from '../../../../common/shared_imports'; import { buildRuleMessageFactory } from './rule_messages'; import { ExceptionListClient } from '../../../../../lists/server'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; @@ -24,7 +23,6 @@ import { getGapMaxCatchupRatio, errorAggregator, getListsClient, - hasLargeValueList, getSignalTimeTuples, getExceptions, } from './utils'; @@ -585,53 +583,6 @@ describe('utils', () => { }); }); - describe('#hasLargeValueList', () => { - test('it returns false if empty array', () => { - const hasLists = hasLargeValueList([]); - - expect(hasLists).toBeFalsy(); - }); - - test('it returns true if item of type EntryList exists', () => { - const entries: EntriesArray = [ - { - field: 'actingProcess.file.signer', - type: 'list', - operator: 'included', - list: { id: 'some id', type: 'ip' }, - }, - { - field: 'file.signature.signer', - type: 'match', - operator: 'excluded', - value: 'Global Signer', - }, - ]; - const hasLists = hasLargeValueList(entries); - - expect(hasLists).toBeTruthy(); - }); - - test('it returns false if item of type EntryList does not exist', () => { - const entries: EntriesArray = [ - { - field: 'actingProcess.file.signer', - type: 'match', - operator: 'included', - value: 'Elastic, N.V.', - }, - { - field: 'file.signature.signer', - type: 'match', - operator: 'excluded', - value: 'Global Signer', - }, - ]; - const hasLists = hasLargeValueList(entries); - - expect(hasLists).toBeFalsy(); - }); - }); describe('getSignalTimeTuples', () => { test('should return a single tuple if no gap', () => { const someTuples = getSignalTimeTuples({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 0b95ff6786b01..1c59a4b7ea5d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -10,10 +10,11 @@ import dateMath from '@elastic/datemath'; import { Logger, SavedObjectsClientContract } from '../../../../../../../src/core/server'; import { AlertServices, parseDuration } from '../../../../../alerts/server'; import { ExceptionListClient, ListClient, ListPluginSetup } from '../../../../../lists/server'; -import { EntriesArray, ExceptionListItemSchema } from '../../../../../lists/common/schemas'; +import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; import { BulkResponse, BulkResponseErrorAggregation, isValidUnit } from './types'; import { BuildRuleMessage } from './rule_messages'; +import { hasLargeValueList } from '../../../../common/detection_engine/utils'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -148,11 +149,6 @@ export const getListsClient = async ({ return { listClient, exceptionsClient }; }; -export const hasLargeValueList = (entries: EntriesArray): boolean => { - const found = entries.filter(({ type }) => type === 'list'); - return found.length > 0; -}; - export const getExceptions = async ({ client, lists, From f69edbd89bb5a3b3b4f4325156c9a4174f4787d7 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Wed, 15 Jul 2020 07:17:54 -0500 Subject: [PATCH 03/52] [APM] Add error rates to Service Map popovers (#69520) Make the `getErrorRate` function used in the error rate charts additionally take `service.environment` as a filter and have it return the `average` of the values. Call that function in the API for the service map metrics. Fixes #68160. Co-authored-by: cauemarcondes --- x-pack/plugins/apm/common/service_map.ts | 4 +- .../app/ServiceMap/Popover/Contents.tsx | 4 +- .../app/ServiceMap/Popover/Info.tsx | 4 +- .../ServiceMap/Popover/Popover.stories.tsx | 156 +++++-- ...ricFetcher.tsx => ServiceStatsFetcher.tsx} | 31 +- ...iceMetricList.tsx => ServiceStatsList.tsx} | 36 +- .../get_parsed_ui_filters.ts | 23 + .../get_service_map_service_node_info.test.ts | 81 ++++ .../get_service_map_service_node_info.ts | 100 ++-- .../lib/transaction_groups/get_error_rate.ts | 11 +- .../plugins/apm/server/routes/service_map.ts | 17 +- .../apm/server/routes/transaction_groups.ts | 10 +- .../translations/translations/ja-JP.json | 7 - .../translations/translations/zh-CN.json | 7 - .../trial/tests/service_maps.ts | 428 ++++++++++-------- 15 files changed, 568 insertions(+), 351 deletions(-) rename x-pack/plugins/apm/public/components/app/ServiceMap/Popover/{ServiceMetricFetcher.tsx => ServiceStatsFetcher.tsx} (78%) rename x-pack/plugins/apm/public/components/app/ServiceMap/Popover/{ServiceMetricList.tsx => ServiceStatsList.tsx} (75%) create mode 100644 x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_parsed_ui_filters.ts create mode 100644 x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index b50db270ef544..7f46fc685d9ca 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -36,14 +36,14 @@ export interface Connection { destination: ConnectionNode; } -export interface ServiceNodeMetrics { +export interface ServiceNodeStats { avgMemoryUsage: number | null; avgCpuUsage: number | null; transactionStats: { avgTransactionDuration: number | null; avgRequestsPerMinute: number | null; }; - avgErrorsPerMinute: number | null; + avgErrorRate: number | null; } export function isValidPlatinumLicense(license: ILicense) { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index c696a93773ceb..78466b2659bb7 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -14,7 +14,7 @@ import cytoscape from 'cytoscape'; import React, { MouseEvent } from 'react'; import { Buttons } from './Buttons'; import { Info } from './Info'; -import { ServiceMetricFetcher } from './ServiceMetricFetcher'; +import { ServiceStatsFetcher } from './ServiceStatsFetcher'; import { popoverWidth } from '../cytoscapeOptions'; interface ContentsProps { @@ -70,7 +70,7 @@ export function Contents({ {isService ? ( - diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx index 223d342e6799f..094cf032c4c9d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx @@ -38,13 +38,13 @@ export function Info(data: InfoProps) { const listItems = [ { - title: i18n.translate('xpack.apm.serviceMap.typePopoverMetric', { + title: i18n.translate('xpack.apm.serviceMap.typePopoverStat', { defaultMessage: 'Type', }), description: type, }, { - title: i18n.translate('xpack.apm.serviceMap.subtypePopoverMetric', { + title: i18n.translate('xpack.apm.serviceMap.subtypePopoverStat', { defaultMessage: 'Subtype', }), description: subtype, diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index ccf147ed1d90d..20f6f92f9995f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -5,40 +5,128 @@ */ import { storiesOf } from '@storybook/react'; +import cytoscape from 'cytoscape'; +import { HttpSetup } from 'kibana/public'; import React from 'react'; -import { ServiceMetricList } from './ServiceMetricList'; +import { EuiThemeProvider } from '../../../../../../observability/public'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +import { MockUrlParamsContextProvider } from '../../../../context/UrlParamsContext/MockUrlParamsContextProvider'; +import { createCallApmApi } from '../../../../services/rest/createCallApmApi'; +import { CytoscapeContext } from '../Cytoscape'; +import { Popover } from './'; +import { ServiceStatsList } from './ServiceStatsList'; -storiesOf('app/ServiceMap/Popover/ServiceMetricList', module) - .add('example', () => ( - { + const node = { + data: { id: 'example service', 'service.name': 'example service' }, + }; + const cy = cytoscape({ elements: [node] }); + const httpMock = ({ + get: async () => ({ + avgCpuUsage: 0.32809666568309237, + avgErrorRate: 0.556068173242986, + avgMemoryUsage: 0.5504868173242986, avgRequestsPerMinute: 164.47222031860858, - }} - avgCpuUsage={0.32809666568309237} - avgMemoryUsage={0.5504868173242986} - /> - )) - .add('some null values', () => ( - - )) - .add('all null values', () => ( - - )); + avgTransactionDuration: 61634.38905590272, + }), + } as unknown) as HttpSetup; + + createCallApmApi(httpMock); + + setImmediate(() => { + cy.$('example service').select(); + }); + + return ( + + + + +
{storyFn()}
+
+
+
+
+ ); + }) + .add( + 'example', + () => { + return ; + }, + { + info: { + propTablesExclude: [ + CytoscapeContext.Provider, + MockApmPluginContextWrapper, + MockUrlParamsContextProvider, + EuiThemeProvider, + ], + source: false, + }, + } + ); + +storiesOf('app/ServiceMap/Popover/ServiceStatsList', module) + .addDecorator((storyFn) => {storyFn()}) + .add( + 'example', + () => ( + + ), + { info: { propTablesExclude: [EuiThemeProvider] } } + ) + .add( + 'loading', + () => ( + + ), + { info: { propTablesExclude: [EuiThemeProvider] } } + ) + .add( + 'some null values', + () => ( + + ), + { info: { propTablesExclude: [EuiThemeProvider] } } + ) + .add( + 'all null values', + () => ( + + ), + { info: { propTablesExclude: [EuiThemeProvider] } } + ); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx similarity index 78% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx index 957678877a134..9e8f1f7a0171e 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsFetcher.tsx @@ -13,39 +13,44 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isNumber } from 'lodash'; -import { ServiceNodeMetrics } from '../../../../../common/service_map'; +import { ServiceNodeStats } from '../../../../../common/service_map'; +import { ServiceStatsList } from './ServiceStatsList'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { ServiceMetricList } from './ServiceMetricList'; import { AnomalyDetection } from './AnomalyDetection'; import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; -interface ServiceMetricFetcherProps { +interface ServiceStatsFetcherProps { + environment?: string; serviceName: string; serviceAnomalyStats: ServiceAnomalyStats | undefined; } -export function ServiceMetricFetcher({ +export function ServiceStatsFetcher({ serviceName, serviceAnomalyStats, -}: ServiceMetricFetcherProps) { +}: ServiceStatsFetcherProps) { const { - urlParams: { start, end, environment }, + urlParams: { start, end }, + uiFilters, } = useUrlParams(); const { - data = { transactionStats: {} } as ServiceNodeMetrics, + data = { transactionStats: {} } as ServiceNodeStats, status, } = useFetcher( (callApmApi) => { if (serviceName && start && end) { return callApmApi({ pathname: '/api/apm/service-map/service/{serviceName}', - params: { path: { serviceName }, query: { start, end, environment } }, + params: { + path: { serviceName }, + query: { start, end, uiFilters: JSON.stringify(uiFilters) }, + }, }); } }, - [serviceName, start, end, environment], + [serviceName, start, end, uiFilters], { preservePreviousData: false, } @@ -60,20 +65,20 @@ export function ServiceMetricFetcher({ const { avgCpuUsage, - avgErrorsPerMinute, + avgErrorRate, avgMemoryUsage, transactionStats: { avgRequestsPerMinute, avgTransactionDuration }, } = data; const hasServiceData = [ avgCpuUsage, - avgErrorsPerMinute, + avgErrorRate, avgMemoryUsage, avgRequestsPerMinute, avgTransactionDuration, ].some((stat) => isNumber(stat)); - if (environment && !hasServiceData) { + if (!hasServiceData) { return ( {i18n.translate('xpack.apm.serviceMap.popoverMetrics.noDataText', { @@ -93,7 +98,7 @@ export function ServiceMetricFetcher({ )} - + ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx similarity index 75% rename from x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx index f82f434e7ded1..4a1a291249f50 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceStatsList.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { isNumber } from 'lodash'; import React from 'react'; import styled from 'styled-components'; -import { ServiceNodeMetrics } from '../../../../../common/service_map'; +import { ServiceNodeStats } from '../../../../../common/service_map'; import { asDuration, asPercent, tpmUnit } from '../../../../utils/formatters'; export const ItemRow = styled('tr')` @@ -24,18 +24,18 @@ export const ItemDescription = styled('td')` text-align: right; `; -type ServiceMetricListProps = ServiceNodeMetrics; +type ServiceStatsListProps = ServiceNodeStats; -export function ServiceMetricList({ - avgErrorsPerMinute, +export function ServiceStatsList({ + transactionStats, + avgErrorRate, avgCpuUsage, avgMemoryUsage, - transactionStats, -}: ServiceMetricListProps) { +}: ServiceStatsListProps) { const listItems = [ { title: i18n.translate( - 'xpack.apm.serviceMap.avgTransDurationPopoverMetric', + 'xpack.apm.serviceMap.avgTransDurationPopoverStat', { defaultMessage: 'Trans. duration (avg.)', } @@ -58,27 +58,21 @@ export function ServiceMetricList({ : null, }, { - title: i18n.translate( - 'xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric', - { - defaultMessage: 'Errors per minute (avg.)', - } - ), - description: avgErrorsPerMinute?.toFixed(2), + title: i18n.translate('xpack.apm.serviceMap.errorRatePopoverStat', { + defaultMessage: 'Error rate (avg.)', + }), + description: isNumber(avgErrorRate) ? asPercent(avgErrorRate, 1) : null, }, { - title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverMetric', { + title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverStat', { defaultMessage: 'CPU usage (avg.)', }), description: isNumber(avgCpuUsage) ? asPercent(avgCpuUsage, 1) : null, }, { - title: i18n.translate( - 'xpack.apm.serviceMap.avgMemoryUsagePopoverMetric', - { - defaultMessage: 'Memory usage (avg.)', - } - ), + title: i18n.translate('xpack.apm.serviceMap.avgMemoryUsagePopoverStat', { + defaultMessage: 'Memory usage (avg.)', + }), description: isNumber(avgMemoryUsage) ? asPercent(avgMemoryUsage, 1) : null, diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_parsed_ui_filters.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_parsed_ui_filters.ts new file mode 100644 index 0000000000000..324da199807c7 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_parsed_ui_filters.ts @@ -0,0 +1,23 @@ +/* + * 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 { Logger } from 'src/core/server'; +import { UIFilters } from '../../../../typings/ui_filters'; + +export function getParsedUiFilters({ + uiFilters, + logger, +}: { + uiFilters: string; + logger: Logger; +}): UIFilters { + try { + return JSON.parse(uiFilters); + } catch (error) { + logger.error(error); + } + return {}; +} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts new file mode 100644 index 0000000000000..1e0d001340edf --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { getServiceMapServiceNodeInfo } from './get_service_map_service_node_info'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import * as getErrorRateModule from '../transaction_groups/get_error_rate'; + +describe('getServiceMapServiceNodeInfo', () => { + describe('with no results', () => { + it('returns null data', async () => { + const setup = ({ + client: { + search: () => + Promise.resolve({ + hits: { total: { value: 0 } }, + }), + }, + indices: {}, + } as unknown) as Setup & SetupTimeRange; + const environment = 'test environment'; + const serviceName = 'test service name'; + const result = await getServiceMapServiceNodeInfo({ + uiFilters: { environment }, + setup, + serviceName, + }); + + expect(result).toEqual({ + avgCpuUsage: null, + avgErrorRate: null, + avgMemoryUsage: null, + transactionStats: { + avgRequestsPerMinute: null, + avgTransactionDuration: null, + }, + }); + }); + }); + + describe('with some results', () => { + it('returns data', async () => { + jest.spyOn(getErrorRateModule, 'getErrorRate').mockResolvedValueOnce({ + average: 0.5, + erroneousTransactionsRate: [], + noHits: false, + }); + + const setup = ({ + client: { + search: () => + Promise.resolve({ + hits: { total: { value: 1 } }, + }), + }, + indices: {}, + start: 1593460053026000, + end: 1593497863217000, + } as unknown) as Setup & SetupTimeRange; + const environment = 'test environment'; + const serviceName = 'test service name'; + const result = await getServiceMapServiceNodeInfo({ + uiFilters: { environment }, + setup, + serviceName, + }); + + expect(result).toEqual({ + avgCpuUsage: null, + avgErrorRate: 0.5, + avgMemoryUsage: null, + transactionStats: { + avgRequestsPerMinute: 0.000001586873761097901, + avgTransactionDuration: null, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index dd5d19b620c51..0f7136d6d74a4 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -4,23 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { ESFilter } from '../../../typings/elasticsearch'; -import { rangeFilter } from '../../../common/utils/range_filter'; +import { UIFilters } from '../../../typings/ui_filters'; import { - PROCESSOR_EVENT, - SERVICE_NAME, - TRANSACTION_DURATION, TRANSACTION_TYPE, METRIC_SYSTEM_CPU_PERCENT, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, + PROCESSOR_EVENT, + SERVICE_NAME, + TRANSACTION_DURATION, } from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { ESFilter } from '../../../typings/elasticsearch'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory'; import { TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD, } from '../../../common/transaction_types'; +import { getErrorRate } from '../transaction_groups/get_error_rate'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; interface Options { @@ -30,69 +33,72 @@ interface Options { } interface TaskParameters { - setup: Setup; - minutes: number; + environment?: string; filter: ESFilter[]; + minutes: number; + serviceName?: string; + setup: Setup; } export async function getServiceMapServiceNodeInfo({ serviceName, - environment, setup, -}: Options & { serviceName: string; environment?: string }) { + uiFilters, +}: Options & { serviceName: string; uiFilters: UIFilters }) { const { start, end } = setup; const filter: ESFilter[] = [ { range: rangeFilter(start, end) }, { term: { [SERVICE_NAME]: serviceName } }, - ...getEnvironmentUiFilterES(environment), + ...getEnvironmentUiFilterES(uiFilters.environment), ]; const minutes = Math.abs((end - start) / (1000 * 60)); - const taskParams = { setup, minutes, filter }; + const taskParams = { + environment: uiFilters.environment, + filter, + minutes, + serviceName, + setup, + }; const [ - errorMetrics, + errorStats, transactionStats, - cpuMetrics, - memoryMetrics, + cpuStats, + memoryStats, ] = await Promise.all([ - getErrorMetrics(taskParams), + getErrorStats(taskParams), getTransactionStats(taskParams), - getCpuMetrics(taskParams), - getMemoryMetrics(taskParams), + getCpuStats(taskParams), + getMemoryStats(taskParams), ]); - return { - ...errorMetrics, + ...errorStats, transactionStats, - ...cpuMetrics, - ...memoryMetrics, + ...cpuStats, + ...memoryStats, }; } -async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) { - const { client, indices } = setup; - - const response = await client.search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: filter.concat({ term: { [PROCESSOR_EVENT]: 'error' } }), - }, - }, - track_total_hits: true, - }, - }); - - return { - avgErrorsPerMinute: - response.hits.total.value > 0 - ? response.hits.total.value / minutes - : null, +async function getErrorStats({ + setup, + serviceName, + environment, +}: { + setup: Options['setup']; + serviceName: string; + environment?: string; +}) { + const setupWithBlankUiFilters = { + ...setup, + uiFiltersES: getEnvironmentUiFilterES(environment), }; + const { noHits, average } = await getErrorRate({ + setup: setupWithBlankUiFilters, + serviceName, + }); + return { avgErrorRate: noHits ? null : average }; } async function getTransactionStats({ @@ -113,7 +119,7 @@ async function getTransactionStats({ bool: { filter: [ ...filter, - { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { terms: { [TRANSACTION_TYPE]: [ @@ -137,7 +143,7 @@ async function getTransactionStats({ }; } -async function getCpuMetrics({ +async function getCpuStats({ setup, filter, }: TaskParameters): Promise<{ avgCpuUsage: number | null }> { @@ -150,7 +156,7 @@ async function getCpuMetrics({ query: { bool: { filter: filter.concat([ - { term: { [PROCESSOR_EVENT]: 'metric' } }, + { term: { [PROCESSOR_EVENT]: ProcessorEvent.metric } }, { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }, ]), }, @@ -162,7 +168,7 @@ async function getCpuMetrics({ return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; } -async function getMemoryMetrics({ +async function getMemoryStats({ setup, filter, }: TaskParameters): Promise<{ avgMemoryUsage: number | null }> { diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts index 5b66f7d7a45e7..6a1ee8daad7c7 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -3,11 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { mean } from 'lodash'; import { PROCESSOR_EVENT, HTTP_RESPONSE_STATUS_CODE, TRANSACTION_NAME, TRANSACTION_TYPE, + SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; import { ProcessorEvent } from '../../../common/processor_event'; import { rangeFilter } from '../../../common/utils/range_filter'; @@ -39,6 +41,7 @@ export async function getErrorRate({ : []; const filter = [ + { term: { [SERVICE_NAME]: serviceName } }, { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { range: rangeFilter(start, end) }, { exists: { field: HTTP_RESPONSE_STATUS_CODE } }, @@ -82,5 +85,11 @@ export async function getErrorRate({ } ) || []; - return { noHits, erroneousTransactionsRate }; + const average = mean( + erroneousTransactionsRate + .map((errorRate) => errorRate.y) + .filter((y) => isFinite(y)) + ); + + return { noHits, erroneousTransactionsRate, average }; } diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index 50123131a42e7..971e247d98986 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -14,8 +14,9 @@ import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; -import { rangeRt } from './default_api_types'; +import { rangeRt, uiFiltersRt } from './default_api_types'; import { APM_SERVICE_MAPS_FEATURE_NAME } from '../feature'; +import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters'; export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', @@ -52,12 +53,7 @@ export const serviceMapServiceNodeRoute = createRoute(() => ({ path: t.type({ serviceName: t.string, }), - query: t.intersection([ - rangeRt, - t.partial({ - environment: t.string, - }), - ]), + query: t.intersection([rangeRt, uiFiltersRt]), }, handler: async ({ context, request }) => { if (!context.config['xpack.apm.serviceMapEnabled']) { @@ -66,17 +62,20 @@ export const serviceMapServiceNodeRoute = createRoute(() => ({ if (!isValidPlatinumLicense(context.licensing.license)) { throw Boom.forbidden(invalidLicenseMessage); } + const logger = context.logger; const setup = await setupRequest(context, request); const { - query: { environment }, + query: { uiFilters: uiFiltersJson }, path: { serviceName }, } = context.params; + const uiFilters = getParsedUiFilters({ uiFilters: uiFiltersJson, logger }); + return getServiceMapServiceNodeInfo({ setup, serviceName, - environment, + uiFilters, }); }, })); diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index dca2fb1d9b295..813d757c7c33e 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -15,7 +15,7 @@ import { uiFiltersRt, rangeRt } from './default_api_types'; import { getTransactionAvgDurationByBrowser } from '../lib/transactions/avg_duration_by_browser'; import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country'; import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; -import { UIFilters } from '../../typings/ui_filters'; +import { getParsedUiFilters } from '../lib/helpers/convert_ui_filters/get_parsed_ui_filters'; export const transactionGroupsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/transaction_groups', @@ -71,12 +71,8 @@ export const transactionGroupsChartsRoute = createRoute(() => ({ transactionName, uiFilters: uiFiltersJson, } = context.params.query; - let uiFilters: UIFilters = {}; - try { - uiFilters = JSON.parse(uiFiltersJson); - } catch (error) { - logger.error(error); - } + + const uiFilters = getParsedUiFilters({ uiFilters: uiFiltersJson, logger }); return getTransactionCharts({ serviceName, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b54f88f83fbe0..a4100ae914b25 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4286,11 +4286,6 @@ "xpack.apm.serviceDetails.metricsTabLabel": "メトリック", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", "xpack.apm.serviceDetails.transactionsTabLabel": "トランザクション", - "xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU使用状況 (平均)", - "xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "1分あたりのエラー(平均)", - "xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "メモリー使用状況(平均)", - "xpack.apm.serviceMap.avgReqPerMinutePopoverMetric": "1分あたりのリクエスト(平均)", - "xpack.apm.serviceMap.avgTransDurationPopoverMetric": "トランザクションの長さ(平均)", "xpack.apm.serviceMap.betaBadge": "ベータ", "xpack.apm.serviceMap.betaTooltipMessage": "現在、この機能はベータです。不具合を見つけた場合やご意見がある場合、サポートに問い合わせるか、またはディスカッションフォーラムにご報告ください。", "xpack.apm.serviceMap.center": "中央", @@ -4300,8 +4295,6 @@ "xpack.apm.serviceMap.focusMapButtonText": "焦点マップ", "xpack.apm.serviceMap.invalidLicenseMessage": "サービスマップを利用するには、Elastic Platinum ライセンスが必要です。これにより、APM データとともにアプリケーションスタック全てを可視化することができるようになります。", "xpack.apm.serviceMap.serviceDetailsButtonText": "サービス詳細", - "xpack.apm.serviceMap.subtypePopoverMetric": "サブタイプ", - "xpack.apm.serviceMap.typePopoverMetric": "タイプ", "xpack.apm.serviceMap.viewFullMap": "サービスの全体マップを表示", "xpack.apm.serviceMap.zoomIn": "ズームイン", "xpack.apm.serviceMap.zoomOut": "ズームアウト", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 389e0083d5a9f..69e37f3f9f9f0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4290,11 +4290,6 @@ "xpack.apm.serviceDetails.metricsTabLabel": "指标", "xpack.apm.serviceDetails.nodesTabLabel": "JVM", "xpack.apm.serviceDetails.transactionsTabLabel": "事务", - "xpack.apm.serviceMap.avgCpuUsagePopoverMetric": "CPU 使用(平均)", - "xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric": "每分钟错误数(平均)", - "xpack.apm.serviceMap.avgMemoryUsagePopoverMetric": "内存使用(平均)", - "xpack.apm.serviceMap.avgReqPerMinutePopoverMetric": "每分钟请求数(平均)", - "xpack.apm.serviceMap.avgTransDurationPopoverMetric": "事务持续时间(平均)", "xpack.apm.serviceMap.betaBadge": "公测版", "xpack.apm.serviceMap.betaTooltipMessage": "此功能当前为公测版。如果遇到任何错误或有任何反馈,请报告问题或访问我们的论坛。", "xpack.apm.serviceMap.center": "中", @@ -4304,8 +4299,6 @@ "xpack.apm.serviceMap.focusMapButtonText": "聚焦地图", "xpack.apm.serviceMap.invalidLicenseMessage": "要访问服务地图,必须订阅 Elastic 白金级许可证。使用该许可证,您将能够可视化整个应用程序堆栈以及 APM 数据。", "xpack.apm.serviceMap.serviceDetailsButtonText": "服务详情", - "xpack.apm.serviceMap.subtypePopoverMetric": "子类型", - "xpack.apm.serviceMap.typePopoverMetric": "类型", "xpack.apm.serviceMap.viewFullMap": "查看完整的服务地图", "xpack.apm.serviceMap.zoomIn": "放大", "xpack.apm.serviceMap.zoomOut": "缩小", diff --git a/x-pack/test/apm_api_integration/trial/tests/service_maps.ts b/x-pack/test/apm_api_integration/trial/tests/service_maps.ts index cf265c3fb6737..0b370f6a30a8b 100644 --- a/x-pack/test/apm_api_integration/trial/tests/service_maps.ts +++ b/x-pack/test/apm_api_integration/trial/tests/service_maps.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import querystring from 'querystring'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -11,159 +12,224 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext) const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const start = encodeURIComponent('2020-06-29T06:45:00.000Z'); - const end = encodeURIComponent('2020-06-29T06:49:00.000Z'); + describe('Service Maps with a trial license', () => { + describe('/api/apm/service-map', () => { + describe('when there is no data', () => { + it('returns empty list', async () => { + const response = await supertest.get( + '/api/apm/service-map?start=2020-06-28T10%3A24%3A46.055Z&end=2020-06-29T10%3A24%3A46.055Z' + ); - describe('Service Maps', () => { - describe('when there is no data', () => { - it('returns empty list', async () => { - const response = await supertest.get(`/api/apm/service-map?start=${start}&end=${end}`); - - expect(response.status).to.be(200); - expect(response.body).to.eql({ elements: [] }); + expect(response.status).to.be(200); + expect(response.body).to.eql({ elements: [] }); + }); }); - }); - describe('when there is data', () => { - before(() => esArchiver.load('8.0.0')); - after(() => esArchiver.unload('8.0.0')); + describe('when there is data', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); - it('returns service map elements', async () => { - const response = await supertest.get(`/api/apm/service-map?start=${start}&end=${end}`); + it('returns service map elements', async () => { + const response = await supertest.get( + '/api/apm/service-map?start=2020-06-28T10%3A24%3A46.055Z&end=2020-06-29T10%3A24%3A46.055Z' + ); - expect(response.status).to.be(200); - expect(response.body).to.eql({ - elements: [ - { - data: { - source: 'client', - target: 'opbeans-node', - id: 'client~opbeans-node', - sourceData: { - id: 'client', - 'service.name': 'client', - 'agent.name': 'rum-js', + expect(response.status).to.be(200); + + expect(response.body).to.eql({ + elements: [ + { + data: { + source: 'client', + target: 'opbeans-node', + id: 'client~opbeans-node', + sourceData: { + id: 'client', + 'service.name': 'client', + 'agent.name': 'rum-js', + }, + targetData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, }, - targetData: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', + }, + { + data: { + source: 'opbeans-java', + target: '>opbeans-java:3000', + id: 'opbeans-java~>opbeans-java:3000', + sourceData: { + id: 'opbeans-java', + 'service.environment': 'production', + 'service.name': 'opbeans-java', + 'agent.name': 'java', + }, + targetData: { + 'span.subtype': 'http', + 'span.destination.service.resource': 'opbeans-java:3000', + 'span.type': 'external', + id: '>opbeans-java:3000', + label: 'opbeans-java:3000', + }, }, }, - }, - { - data: { - source: 'opbeans-java', - target: '>opbeans-java:3000', - id: 'opbeans-java~>opbeans-java:3000', - sourceData: { - id: 'opbeans-java', - 'service.environment': 'production', - 'service.name': 'opbeans-java', - 'agent.name': 'java', + { + data: { + source: 'opbeans-java', + target: '>postgresql', + id: 'opbeans-java~>postgresql', + sourceData: { + id: 'opbeans-java', + 'service.environment': 'production', + 'service.name': 'opbeans-java', + 'agent.name': 'java', + }, + targetData: { + 'span.subtype': 'postgresql', + 'span.destination.service.resource': 'postgresql', + 'span.type': 'db', + id: '>postgresql', + label: 'postgresql', + }, }, - targetData: { - 'span.subtype': 'http', - 'span.destination.service.resource': 'opbeans-java:3000', - 'span.type': 'external', - id: '>opbeans-java:3000', - label: 'opbeans-java:3000', + }, + { + data: { + source: 'opbeans-java', + target: 'opbeans-node', + id: 'opbeans-java~opbeans-node', + sourceData: { + id: 'opbeans-java', + 'service.environment': 'production', + 'service.name': 'opbeans-java', + 'agent.name': 'java', + }, + targetData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + bidirectional: true, }, }, - }, - { - data: { - source: 'opbeans-java', - target: '>postgresql', - id: 'opbeans-java~>postgresql', - sourceData: { - id: 'opbeans-java', - 'service.environment': 'production', - 'service.name': 'opbeans-java', - 'agent.name': 'java', + { + data: { + source: 'opbeans-node', + target: '>93.184.216.34:80', + id: 'opbeans-node~>93.184.216.34:80', + sourceData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + targetData: { + 'span.subtype': 'http', + 'span.destination.service.resource': '93.184.216.34:80', + 'span.type': 'external', + id: '>93.184.216.34:80', + label: '93.184.216.34:80', + }, }, - targetData: { - 'span.subtype': 'postgresql', - 'span.destination.service.resource': 'postgresql', - 'span.type': 'db', - id: '>postgresql', - label: 'postgresql', + }, + { + data: { + source: 'opbeans-node', + target: '>postgresql', + id: 'opbeans-node~>postgresql', + sourceData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + targetData: { + 'span.subtype': 'postgresql', + 'span.destination.service.resource': 'postgresql', + 'span.type': 'db', + id: '>postgresql', + label: 'postgresql', + }, }, }, - }, - { - data: { - source: 'opbeans-java', - target: 'opbeans-node', - id: 'opbeans-java~opbeans-node', - sourceData: { + { + data: { + source: 'opbeans-node', + target: '>redis', + id: 'opbeans-node~>redis', + sourceData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + targetData: { + 'span.subtype': 'redis', + 'span.destination.service.resource': 'redis', + 'span.type': 'cache', + id: '>redis', + label: 'redis', + }, + }, + }, + { + data: { + source: 'opbeans-node', + target: 'opbeans-java', + id: 'opbeans-node~opbeans-java', + sourceData: { + id: 'opbeans-node', + 'service.environment': 'production', + 'service.name': 'opbeans-node', + 'agent.name': 'nodejs', + }, + targetData: { + id: 'opbeans-java', + 'service.environment': 'production', + 'service.name': 'opbeans-java', + 'agent.name': 'java', + }, + isInverseEdge: true, + }, + }, + { + data: { id: 'opbeans-java', 'service.environment': 'production', 'service.name': 'opbeans-java', 'agent.name': 'java', }, - targetData: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - bidirectional: true, }, - }, - { - data: { - source: 'opbeans-node', - target: '>93.184.216.34:80', - id: 'opbeans-node~>93.184.216.34:80', - sourceData: { + { + data: { id: 'opbeans-node', 'service.environment': 'production', 'service.name': 'opbeans-node', 'agent.name': 'nodejs', }, - targetData: { + }, + { + data: { 'span.subtype': 'http', - 'span.destination.service.resource': '93.184.216.34:80', + 'span.destination.service.resource': 'opbeans-java:3000', 'span.type': 'external', - id: '>93.184.216.34:80', - label: '93.184.216.34:80', + id: '>opbeans-java:3000', + label: 'opbeans-java:3000', }, }, - }, - { - data: { - source: 'opbeans-node', - target: '>postgresql', - id: 'opbeans-node~>postgresql', - sourceData: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - targetData: { - 'span.subtype': 'postgresql', - 'span.destination.service.resource': 'postgresql', - 'span.type': 'db', - id: '>postgresql', - label: 'postgresql', + { + data: { + id: 'client', + 'service.name': 'client', + 'agent.name': 'rum-js', }, }, - }, - { - data: { - source: 'opbeans-node', - target: '>redis', - id: 'opbeans-node~>redis', - sourceData: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - targetData: { + { + data: { 'span.subtype': 'redis', 'span.destination.service.resource': 'redis', 'span.type': 'cache', @@ -171,87 +237,51 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext) label: 'redis', }, }, - }, - { - data: { - source: 'opbeans-node', - target: 'opbeans-java', - id: 'opbeans-node~opbeans-java', - sourceData: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - targetData: { - id: 'opbeans-java', - 'service.environment': 'production', - 'service.name': 'opbeans-java', - 'agent.name': 'java', + { + data: { + 'span.subtype': 'postgresql', + 'span.destination.service.resource': 'postgresql', + 'span.type': 'db', + id: '>postgresql', + label: 'postgresql', }, - isInverseEdge: true, - }, - }, - { - data: { - id: 'opbeans-java', - 'service.environment': 'production', - 'service.name': 'opbeans-java', - 'agent.name': 'java', - }, - }, - { - data: { - id: 'opbeans-node', - 'service.environment': 'production', - 'service.name': 'opbeans-node', - 'agent.name': 'nodejs', - }, - }, - { - data: { - 'span.subtype': 'http', - 'span.destination.service.resource': 'opbeans-java:3000', - 'span.type': 'external', - id: '>opbeans-java:3000', - label: 'opbeans-java:3000', - }, - }, - { - data: { - id: 'client', - 'service.name': 'client', - 'agent.name': 'rum-js', - }, - }, - { - data: { - 'span.subtype': 'redis', - 'span.destination.service.resource': 'redis', - 'span.type': 'cache', - id: '>redis', - label: 'redis', - }, - }, - { - data: { - 'span.subtype': 'postgresql', - 'span.destination.service.resource': 'postgresql', - 'span.type': 'db', - id: '>postgresql', - label: 'postgresql', }, - }, - { - data: { - 'span.subtype': 'http', - 'span.destination.service.resource': '93.184.216.34:80', - 'span.type': 'external', - id: '>93.184.216.34:80', - label: '93.184.216.34:80', + { + data: { + 'span.subtype': 'http', + 'span.destination.service.resource': '93.184.216.34:80', + 'span.type': 'external', + id: '>93.184.216.34:80', + label: '93.184.216.34:80', + }, }, + ], + }); + }); + }); + }); + + describe('/api/apm/service-map/service/{serviceName}', () => { + describe('when there is no data', () => { + it('returns an object with nulls', async () => { + const q = querystring.stringify({ + start: '2020-06-28T10:24:46.055Z', + end: '2020-06-29T10:24:46.055Z', + uiFilters: {}, + }); + const response = await supertest.get(`/api/apm/service-map/service/opbeans-node?${q}`); + + expect(response.status).to.be(200); + + expect(response.body).to.eql({ + avgCpuUsage: null, + avgErrorRate: null, + avgMemoryUsage: null, + transactionStats: { + avgRequestsPerMinute: null, + avgTransactionDuration: null, }, - ], + }); }); }); }); From 4c654c4731ebb37729647685f9de9594c251b3a4 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 15 Jul 2020 15:07:52 +0200 Subject: [PATCH 04/52] [ML] Fix UI Actions context menu positioning for the Anomaly Swim Lane (#71839) * [ML] fix swim lane embeddable rerenders * [ML] fix TS --- .../explorer/explorer_swimlane.tsx | 178 +++++++++--------- 1 file changed, 93 insertions(+), 85 deletions(-) diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 0f92278e90445..926f38ac8b552 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -10,7 +10,7 @@ import React from 'react'; import './_explorer.scss'; -import _ from 'lodash'; +import _, { isEqual } from 'lodash'; import d3 from 'd3'; import moment from 'moment'; import DragSelect from 'dragselect'; @@ -60,11 +60,7 @@ export interface ExplorerSwimlaneProps { timeBuckets: InstanceType; swimlaneData: OverallSwimlaneData | ViewBySwimLaneData; swimlaneType: SwimlaneType; - selection?: { - lanes: any[]; - type: string; - times: number[]; - }; + selection?: AppStateSelectedCells; onCellsSelection: (payload?: AppStateSelectedCells) => void; tooltipService: ChartTooltipService; 'data-test-subj'?: string; @@ -82,6 +78,8 @@ export class ExplorerSwimlane extends React.Component { // and intentionally circumvent the component lifecycle when updating it. cellMouseoverActive = true; + selection: AppStateSelectedCells | undefined = undefined; + dragSelectSubscriber: Subscription | null = null; rootNode = React.createRef(); @@ -123,6 +121,8 @@ export class ExplorerSwimlane extends React.Component { onDragStart: (e) => { // make sure we don't trigger text selection on label e.preventDefault(); + // clear previous selection + this.clearSelection(); let target = e.target as HTMLElement; while (target && target !== document.body && !target.classList.contains('sl-cell')) { target = target.parentNode as HTMLElement; @@ -249,7 +249,7 @@ export class ExplorerSwimlane extends React.Component { } if (triggerNewSelection === false) { - this.swimlaneCellClick(); + this.swimLaneSelectionCompleted(); return; } @@ -259,17 +259,84 @@ export class ExplorerSwimlane extends React.Component { times: d3.extent(times), type: swimlaneType, }; - this.swimlaneCellClick(selectedCells); + this.swimLaneSelectionCompleted(selectedCells); } - highlightOverall(times: number[]) { - const overallSwimlane = d3.select('.ml-swimlane-overall'); - times.forEach((time) => { - const overallCell = overallSwimlane - .selectAll(`div[data-time="${time}"]`) - .selectAll('.sl-cell-inner,.sl-cell-inner-dragselect'); - overallCell.classed('sl-cell-inner-selected', true); + /** + * Highlights DOM elements of the swim lane cells + */ + highlightSwimLaneCells(selection: AppStateSelectedCells | undefined) { + const element = d3.select(this.rootNode.current!.parentNode!); + + const { swimlaneType, swimlaneData, filterActive, maskAll } = this.props; + + const { laneLabels: lanes, earliest: startTime, latest: endTime } = swimlaneData; + + // Check for selection and reselect the corresponding swimlane cell + // if the time range and lane label are still in view. + const selectionState = selection; + const selectedType = _.get(selectionState, 'type', undefined); + const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', ''); + + // If a selection was done in the other swimlane, add the "masked" classes + // to de-emphasize the swimlane cells. + if (swimlaneType !== selectedType && selectedType !== undefined) { + element.selectAll('.lane-label').classed('lane-label-masked', true); + element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); + } + + const cellsToSelect: Node[] = []; + const selectedLanes = _.get(selectionState, 'lanes', []); + const selectedTimes = _.get(selectionState, 'times', []); + const selectedTimeExtent = d3.extent(selectedTimes); + + if ( + (swimlaneType !== selectedType || + (swimlaneData.fieldName !== undefined && + swimlaneData.fieldName !== selectionViewByFieldName)) && + filterActive === false + ) { + // Not this swimlane which was selected. + return; + } + + selectedLanes.forEach((selectedLane) => { + if ( + lanes.indexOf(selectedLane) > -1 && + selectedTimeExtent[0] >= startTime && + selectedTimeExtent[1] <= endTime + ) { + // Locate matching cell - look for exact time, otherwise closest before. + const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); + + laneCells.each(function (this: HTMLElement) { + const cell = d3.select(this); + const cellTime = parseInt(cell.attr('data-time'), 10); + if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { + cellsToSelect.push(cell.node()); + } + }); + } }); + + const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { + return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); + }, 0); + + const selectedCellTimes = cellsToSelect.map((e) => { + return (d3.select(e).node() as NodeWithData).__clickData__.time; + }); + + if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { + this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); + } else if (filterActive === true) { + this.maskIrrelevantSwimlanes(Boolean(maskAll)); + } else { + this.clearSelection(); + } + + // cache selection to prevent rerenders + this.selection = selection; } highlightSelection(cellsToSelect: Node[], laneLabels: string[], times: number[]) { @@ -348,7 +415,6 @@ export class ExplorerSwimlane extends React.Component { const { chartWidth, filterActive, - maskAll, timeBuckets, swimlaneData, swimlaneType, @@ -478,7 +544,7 @@ export class ExplorerSwimlane extends React.Component { }) .on('click', () => { if (selection && typeof selection.lanes !== 'undefined') { - this.swimlaneCellClick(); + this.swimLaneSelectionCompleted(); } }) .each(function (this: HTMLElement) { @@ -618,86 +684,28 @@ export class ExplorerSwimlane extends React.Component { } }); - // Check for selection and reselect the corresponding swimlane cell - // if the time range and lane label are still in view. - const selectionState = selection; - const selectedType = _.get(selectionState, 'type', undefined); - const selectionViewByFieldName = _.get(selectionState, 'viewByFieldName', ''); - - // If a selection was done in the other swimlane, add the "masked" classes - // to de-emphasize the swimlane cells. - if (swimlaneType !== selectedType && selectedType !== undefined) { - element.selectAll('.lane-label').classed('lane-label-masked', true); - element.selectAll('.sl-cell-inner').classed('sl-cell-inner-masked', true); - } - this.swimlaneRenderDoneListener(); - if ( - (swimlaneType !== selectedType || - (swimlaneData.fieldName !== undefined && - swimlaneData.fieldName !== selectionViewByFieldName)) && - filterActive === false - ) { - // Not this swimlane which was selected. - return; - } - - const cellsToSelect: Node[] = []; - const selectedLanes = _.get(selectionState, 'lanes', []); - const selectedTimes = _.get(selectionState, 'times', []); - const selectedTimeExtent = d3.extent(selectedTimes); - - selectedLanes.forEach((selectedLane) => { - if ( - lanes.indexOf(selectedLane) > -1 && - selectedTimeExtent[0] >= startTime && - selectedTimeExtent[1] <= endTime - ) { - // Locate matching cell - look for exact time, otherwise closest before. - const laneCells = element.selectAll(`div[data-lane-label="${mlEscape(selectedLane)}"]`); - - laneCells.each(function (this: HTMLElement) { - const cell = d3.select(this); - const cellTime = parseInt(cell.attr('data-time'), 10); - if (cellTime >= selectedTimeExtent[0] && cellTime <= selectedTimeExtent[1]) { - cellsToSelect.push(cell.node()); - } - }); - } - }); - - const selectedMaxBucketScore = cellsToSelect.reduce((maxBucketScore, cell) => { - return Math.max(maxBucketScore, +d3.select(cell).attr('data-bucket-score') || 0); - }, 0); - - const selectedCellTimes = cellsToSelect.map((e) => { - return (d3.select(e).node() as NodeWithData).__clickData__.time; - }); - - if (cellsToSelect.length > 1 || selectedMaxBucketScore > 0) { - this.highlightSelection(cellsToSelect, selectedLanes, selectedCellTimes); - } else if (filterActive === true) { - if (selectedCellTimes.length > 0) { - this.highlightOverall(selectedCellTimes); - } - this.maskIrrelevantSwimlanes(Boolean(maskAll)); - } else { - this.clearSelection(); - } + this.highlightSwimLaneCells(selection); } - shouldComponentUpdate() { - return true; + shouldComponentUpdate(nextProps: ExplorerSwimlaneProps) { + return ( + this.props.chartWidth !== nextProps.chartWidth || + !isEqual(this.props.swimlaneData, nextProps.swimlaneData) || + !isEqual(nextProps.selection, this.selection) + ); } /** * Listener for click events in the swim lane and execute a prop callback. * @param selectedCellsUpdate */ - swimlaneCellClick(selectedCellsUpdate?: AppStateSelectedCells) { + swimLaneSelectionCompleted(selectedCellsUpdate?: AppStateSelectedCells) { // If selectedCells is an empty object we clear any existing selection, // otherwise we save the new selection in AppState and update the Explorer. + this.highlightSwimLaneCells(selectedCellsUpdate); + if (!selectedCellsUpdate) { this.props.onCellsSelection(); } else { From 5f6389af60dff1ec81353a99c281c3b8abbe2e02 Mon Sep 17 00:00:00 2001 From: Ashik Meerankutty Date: Wed, 15 Jul 2020 18:53:03 +0530 Subject: [PATCH 05/52] Convert vis_type_vega to Typescript (#68915) --- package.json | 3 +- renovate.json5 | 8 + .../public/map/service_settings.d.ts | 1 + .../public/components/vega_vis_editor.tsx | 7 +- ...{ems_file_parser.js => ems_file_parser.ts} | 14 +- ...{es_query_parser.js => es_query_parser.ts} | 62 +++-- .../{time_cache.js => time_cache.ts} | 32 ++- .../vis_type_vega/public/data_model/types.ts | 246 ++++++++++++++++ .../{url_parser.js => url_parser.ts} | 6 +- .../public/data_model/{utils.js => utils.ts} | 16 +- .../{vega_parser.js => vega_parser.ts} | 263 +++++++++++------- src/plugins/vis_type_vega/public/vega_fn.ts | 3 +- .../public/vega_request_handler.ts | 3 - yarn.lock | 5 + 14 files changed, 511 insertions(+), 158 deletions(-) rename src/plugins/vis_type_vega/public/data_model/{ems_file_parser.js => ems_file_parser.ts} (86%) rename src/plugins/vis_type_vega/public/data_model/{es_query_parser.js => es_query_parser.ts} (87%) rename src/plugins/vis_type_vega/public/data_model/{time_cache.js => time_cache.ts} (79%) create mode 100644 src/plugins/vis_type_vega/public/data_model/types.ts rename src/plugins/vis_type_vega/public/data_model/{url_parser.js => url_parser.ts} (92%) rename src/plugins/vis_type_vega/public/data_model/{utils.js => utils.ts} (75%) rename src/plugins/vis_type_vega/public/data_model/{vega_parser.js => vega_parser.ts} (74%) diff --git a/package.json b/package.json index 190eb6d7d94b4..53aa6b25f190b 100644 --- a/package.json +++ b/package.json @@ -141,9 +141,9 @@ "@kbn/babel-preset": "1.0.0", "@kbn/config-schema": "1.0.0", "@kbn/i18n": "1.0.0", - "@kbn/telemetry-tools": "1.0.0", "@kbn/interpreter": "1.0.0", "@kbn/pm": "1.0.0", + "@kbn/telemetry-tools": "1.0.0", "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", @@ -345,6 +345,7 @@ "@types/hapi-auth-cookie": "^9.1.0", "@types/has-ansi": "^3.0.0", "@types/history": "^4.7.3", + "@types/hjson": "^2.4.2", "@types/hoek": "^4.1.3", "@types/inert": "^5.1.2", "@types/jest": "^25.2.3", diff --git a/renovate.json5 b/renovate.json5 index 5a807b4b090c1..1ba6dc0ff7e1b 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -426,6 +426,14 @@ '@types/history', ], }, + { + groupSlug: 'hjson', + groupName: 'hjson related packages', + packageNames: [ + 'hjson', + '@types/hjson', + ], + }, { groupSlug: 'inquirer', groupName: 'inquirer related packages', diff --git a/src/plugins/maps_legacy/public/map/service_settings.d.ts b/src/plugins/maps_legacy/public/map/service_settings.d.ts index e265accaeb8fd..105836ff25f8b 100644 --- a/src/plugins/maps_legacy/public/map/service_settings.d.ts +++ b/src/plugins/maps_legacy/public/map/service_settings.d.ts @@ -48,4 +48,5 @@ export interface IServiceSettings { getEMSHotLink(layer: FileLayer): Promise; getTMSServices(): Promise; getFileLayers(): Promise; + getUrlForRegionLayer(layer: FileLayer): Promise; } diff --git a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx index 1da5e7544850a..5e770fcff556d 100644 --- a/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_vis_editor.tsx @@ -20,7 +20,6 @@ import React, { useCallback } from 'react'; import { EuiCodeEditor } from '@elastic/eui'; import compactStringify from 'json-stringify-pretty-compact'; -// @ts-ignore import hjson from 'hjson'; import 'brace/mode/hjson'; import { i18n } from '@kbn/i18n'; @@ -45,7 +44,11 @@ const hjsonStringifyOptions = { keepWsc: true, }; -function format(value: string, stringify: typeof compactStringify, options?: any) { +function format( + value: string, + stringify: typeof hjson.stringify | typeof compactStringify, + options?: any +) { try { const spec = hjson.parse(value, { legacyRoot: false, keepWsc: true }); return stringify(spec, options); diff --git a/src/plugins/vis_type_vega/public/data_model/ems_file_parser.js b/src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts similarity index 86% rename from src/plugins/vis_type_vega/public/data_model/ems_file_parser.js rename to src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts index ecdf6a43d5287..59256d47de97c 100644 --- a/src/plugins/vis_type_vega/public/data_model/ems_file_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/ems_file_parser.ts @@ -18,14 +18,20 @@ */ import { i18n } from '@kbn/i18n'; +// @ts-ignore import { bypassExternalUrlCheck } from '../vega_view/vega_base_view'; +import { IServiceSettings, FileLayer } from '../../../maps_legacy/public'; +import { Data, UrlObject, Requests } from './types'; /** * This class processes all Vega spec customizations, * converting url object parameters into query results. */ export class EmsFileParser { - constructor(serviceSettings) { + _serviceSettings: IServiceSettings; + _fileLayersP?: Promise; + + constructor(serviceSettings: IServiceSettings) { this._serviceSettings = serviceSettings; } @@ -33,7 +39,7 @@ export class EmsFileParser { /** * Update request object, expanding any context-aware keywords */ - parseUrl(obj, url) { + parseUrl(obj: Data, url: UrlObject) { if (typeof url.name !== 'string') { throw new Error( i18n.translate('visTypeVega.emsFileParser.missingNameOfFileErrorMessage', { @@ -59,13 +65,13 @@ export class EmsFileParser { * @param {object[]} requests each object is generated by parseUrl() * @returns {Promise} */ - async populateData(requests) { + async populateData(requests: Requests[]) { if (requests.length === 0) return; const layers = await this._fileLayersP; for (const { obj, name } of requests) { - const foundLayer = layers.find((v) => v.name === name); + const foundLayer = layers?.find((v) => v.name === name); if (!foundLayer) { throw new Error( i18n.translate('visTypeVega.emsFileParser.emsFileNameDoesNotExistErrorMessage', { diff --git a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js b/src/plugins/vis_type_vega/public/data_model/es_query_parser.ts similarity index 87% rename from src/plugins/vis_type_vega/public/data_model/es_query_parser.js rename to src/plugins/vis_type_vega/public/data_model/es_query_parser.ts index f7772ff888a61..4fdd68f9e9dbe 100644 --- a/src/plugins/vis_type_vega/public/data_model/es_query_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/es_query_parser.ts @@ -19,24 +19,38 @@ import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import { isPlainObject, cloneDeep } from 'lodash'; +import { cloneDeep, isPlainObject } from 'lodash'; +import { SearchParams } from 'elasticsearch'; +import { TimeCache } from './time_cache'; +import { SearchAPI } from './search_api'; +import { Opts, Type, Data, UrlObject, Bool, Requests, Query, ContextVarsObject } from './types'; -const TIMEFILTER = '%timefilter%'; -const AUTOINTERVAL = '%autointerval%'; -const MUST_CLAUSE = '%dashboard_context-must_clause%'; -const FILTER_CLAUSE = '%dashboard_context-filter_clause%'; -const MUST_NOT_CLAUSE = '%dashboard_context-must_not_clause%'; +const TIMEFILTER: string = '%timefilter%'; +const AUTOINTERVAL: string = '%autointerval%'; +const MUST_CLAUSE: string = '%dashboard_context-must_clause%'; +const MUST_NOT_CLAUSE: string = '%dashboard_context-must_not_clause%'; +const FILTER_CLAUSE: string = '%dashboard_context-filter_clause%'; // These values may appear in the 'url': { ... } object -const LEGACY_CONTEXT = '%context_query%'; -const CONTEXT = '%context%'; -const TIMEFIELD = '%timefield%'; +const LEGACY_CONTEXT: string = '%context_query%'; +const CONTEXT: string = '%context%'; +const TIMEFIELD: string = '%timefield%'; /** * This class parses ES requests specified in the data.url objects. */ export class EsQueryParser { - constructor(timeCache, searchAPI, filters, onWarning) { + _timeCache: TimeCache; + _searchAPI: SearchAPI; + _filters: Bool; + _onWarning: (...args: string[]) => void; + + constructor( + timeCache: TimeCache, + searchAPI: SearchAPI, + filters: Bool, + onWarning: (...args: string[]) => void + ) { this._timeCache = timeCache; this._searchAPI = searchAPI; this._filters = filters; @@ -47,7 +61,7 @@ export class EsQueryParser { /** * Update request object, expanding any context-aware keywords */ - parseUrl(dataObject, url) { + parseUrl(dataObject: Data, url: UrlObject) { let body = url.body; let context = url[CONTEXT]; delete url[CONTEXT]; @@ -167,13 +181,13 @@ export class EsQueryParser { // Use dashboard context const newQuery = cloneDeep(this._filters); if (timefield) { - newQuery.bool.must.push(body.query); + newQuery.bool!.must!.push(body.query); } body.query = newQuery; } } - this._injectContextVars(body.aggs, false); + this._injectContextVars(body.aggs!, false); return { dataObject, url }; } @@ -182,8 +196,8 @@ export class EsQueryParser { * @param {object[]} requests each object is generated by parseUrl() * @returns {Promise} */ - async populateData(requests) { - const esSearches = requests.map((r) => r.url); + async populateData(requests: Requests[]) { + const esSearches = requests.map((r: Requests) => r.url); const data$ = this._searchAPI.search(esSearches); const results = await data$.toPromise(); @@ -198,7 +212,7 @@ export class EsQueryParser { * @param {*} obj * @param {boolean} isQuery - if true, the `obj` belongs to the req's query portion */ - _injectContextVars(obj, isQuery) { + _injectContextVars(obj: Query | SearchParams['body']['aggs'], isQuery: boolean) { if (obj && typeof obj === 'object') { if (Array.isArray(obj)) { // For arrays, replace MUST_CLAUSE and MUST_NOT_CLAUSE string elements @@ -239,7 +253,7 @@ export class EsQueryParser { } } else { for (const prop of Object.keys(obj)) { - const subObj = obj[prop]; + const subObj = (obj as ContextVarsObject)[prop]; if (!subObj || typeof obj !== 'object') continue; // replace "interval": { "%autointerval%": true|integer } with @@ -260,7 +274,9 @@ export class EsQueryParser { ); } const bounds = this._timeCache.getTimeBounds(); - obj.interval = EsQueryParser._roundInterval((bounds.max - bounds.min) / size); + (obj as ContextVarsObject).interval = EsQueryParser._roundInterval( + (bounds.max - bounds.min) / size + ); continue; } @@ -269,7 +285,7 @@ export class EsQueryParser { case 'min': case 'max': // Replace {"%timefilter%": "min|max", ...} object with a timestamp - obj[prop] = this._getTimeBound(subObj, subObj[TIMEFILTER]); + (obj as ContextVarsObject)[prop] = this._getTimeBound(subObj, subObj[TIMEFILTER]); continue; case true: // Replace {"%timefilter%": true, ...} object with the "range" object @@ -302,7 +318,7 @@ export class EsQueryParser { * @param {object} obj * @return {object} */ - _createRangeFilter(obj) { + _createRangeFilter(obj: Opts) { obj.gte = moment(this._getTimeBound(obj, 'min')).toISOString(); obj.lte = moment(this._getTimeBound(obj, 'max')).toISOString(); obj.format = 'strict_date_optional_time'; @@ -320,9 +336,9 @@ export class EsQueryParser { * @param {'min'|'max'} type * @returns {*} */ - _getTimeBound(opts, type) { + _getTimeBound(opts: Opts, type: Type): number { const bounds = this._timeCache.getTimeBounds(); - let result = bounds[type]; + let result = bounds[type]?.valueOf() || 0; if (opts.shift) { const shift = opts.shift; @@ -380,7 +396,7 @@ export class EsQueryParser { * @param interval (ms) * @returns {string} */ - static _roundInterval(interval) { + static _roundInterval(interval: number): string { switch (true) { case interval <= 500: // <= 0.5s return '100ms'; diff --git a/src/plugins/vis_type_vega/public/data_model/time_cache.js b/src/plugins/vis_type_vega/public/data_model/time_cache.ts similarity index 79% rename from src/plugins/vis_type_vega/public/data_model/time_cache.js rename to src/plugins/vis_type_vega/public/data_model/time_cache.ts index cf241655592f3..27012d3cdc6c2 100644 --- a/src/plugins/vis_type_vega/public/data_model/time_cache.js +++ b/src/plugins/vis_type_vega/public/data_model/time_cache.ts @@ -17,26 +17,36 @@ * under the License. */ +import { TimefilterContract } from '../../../data/public'; +import { TimeRange } from '../../../data/common'; +import { CacheBounds } from './types'; + /** * Optimization caching - always return the same value if queried within this time * @type {number} */ -const AlwaysCacheMaxAge = 40; + +const AlwaysCacheMaxAge: number = 40; /** * This class caches timefilter's bounds to minimize number of server requests */ export class TimeCache { - constructor(timefilter, maxAge) { + _timefilter: TimefilterContract; + _maxAge: number; + _cachedBounds?: CacheBounds; + _cacheTS: number; + _timeRange?: TimeRange; + + constructor(timefilter: TimefilterContract, maxAge: number) { this._timefilter = timefilter; this._maxAge = maxAge; - this._cachedBounds = null; this._cacheTS = 0; } // Simplifies unit testing // noinspection JSMethodCanBeStatic - _now() { + _now(): number { return Date.now(); } @@ -44,10 +54,10 @@ export class TimeCache { * Get cached time range values * @returns {{min: number, max: number}} */ - getTimeBounds() { + getTimeBounds(): CacheBounds { const ts = this._now(); - let bounds; + let bounds: CacheBounds | null = null; if (this._cachedBounds) { const diff = ts - this._cacheTS; @@ -76,7 +86,7 @@ export class TimeCache { return this._cachedBounds; } - setTimeRange(timeRange) { + setTimeRange(timeRange: TimeRange): void { this._timeRange = timeRange; } @@ -85,11 +95,11 @@ export class TimeCache { * @returns {{min: number, max: number}} * @private */ - _getBounds() { - const bounds = this._timefilter.calculateBounds(this._timeRange); + _getBounds(): CacheBounds { + const bounds = this._timefilter.calculateBounds(this._timeRange!); return { - min: bounds.min.valueOf(), - max: bounds.max.valueOf(), + min: bounds.min!.valueOf(), + max: bounds.max!.valueOf(), }; } } diff --git a/src/plugins/vis_type_vega/public/data_model/types.ts b/src/plugins/vis_type_vega/public/data_model/types.ts new file mode 100644 index 0000000000000..9876faf0fc88f --- /dev/null +++ b/src/plugins/vis_type_vega/public/data_model/types.ts @@ -0,0 +1,246 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SearchResponse, SearchParams } from 'elasticsearch'; +import { Filter } from 'src/plugins/data/public'; +import { DslQuery } from 'src/plugins/data/common'; +import { EsQueryParser } from './es_query_parser'; +import { EmsFileParser } from './ems_file_parser'; +import { UrlParser } from './url_parser'; + +interface Body { + aggs?: SearchParams['body']['aggs']; + query?: Query; + timeout?: string; +} + +interface Coordinate { + axis: { + title: string; + }; + field: string; +} + +interface Encoding { + x: Coordinate; + y: Coordinate; +} + +interface AutoSize { + type: string; + contains: string; +} + +interface Padding { + left: number; + right: number; + top: number; + bottom: number; +} + +interface Mark { + color?: string; + fill?: string; +} + +type Renderer = 'svg' | 'canvas'; + +interface VegaSpecConfig extends KibanaConfig { + kibana: KibanaConfig; + padding: Padding; + projection: Projection; + autosize: AutoSize; + tooltips: TooltipConfig; + mark: Mark; +} + +interface Projection { + name: string; +} + +interface RequestDataObject { + values: SearchResponse; +} + +interface RequestObject { + url: string; +} + +type ContextVarsObjectProps = + | string + | { + [CONSTANTS.AUTOINTERVAL]: number; + }; + +type ToolTipPositions = 'top' | 'right' | 'bottom' | 'left'; + +export interface KibanaConfig { + controlsLocation: ControlsLocation; + controlsDirection: ControlsDirection; + hideWarnings: boolean; + type: string; + renderer: Renderer; +} + +export interface VegaSpec { + [index: string]: any; + $schema: string; + data?: Data; + encoding?: Encoding; + mark?: string; + title?: string; + autosize: AutoSize; + projections: Projection[]; + width?: number; + height?: number; + padding?: number | Padding; + _hostConfig?: KibanaConfig; + config: VegaSpecConfig; +} + +export enum CONSTANTS { + TIMEFILTER = '%timefilter%', + CONTEXT = '%context%', + LEGACY_CONTEXT = '%context_query%', + TYPE = '%type%', + SYMBOL = 'Symbol(vega_id)', + AUTOINTERVAL = '%auautointerval%', +} + +export interface Opts { + [index: string]: any; + [CONSTANTS.TIMEFILTER]?: boolean; + gte?: string; + lte?: string; + format?: string; + shift?: number; + unit?: string; +} + +export type Type = 'min' | 'max'; + +export interface TimeBucket { + key_as_string: string; + key: number; + doc_count: number; + [CONSTANTS.SYMBOL]: number; +} + +export interface Bool { + [index: string]: any; + bool?: Bool; + must?: DslQuery[]; + filter?: Filter[]; + should?: never[]; + must_not?: Filter[]; +} + +export interface Query { + range?: { [x: number]: Opts }; + bool?: Bool; +} + +export interface UrlObject { + [index: string]: any; + [CONSTANTS.TIMEFILTER]?: string; + [CONSTANTS.CONTEXT]?: boolean; + [CONSTANTS.LEGACY_CONTEXT]?: string; + [CONSTANTS.TYPE]?: string; + name?: string; + index?: string; + body?: Body; + size?: number; + timeout?: string; +} + +export interface Data { + [index: string]: any; + url?: UrlObject; + values?: unknown; + source?: unknown; +} + +export interface CacheOptions { + max: number; + maxAge: number; +} + +export interface CacheBounds { + min: number; + max: number; +} + +export interface Requests extends RequestObject { + obj: RequestObject; + name: string; + dataObject: RequestDataObject; +} + +export interface ContextVarsObject { + [index: string]: any; + prop: ContextVarsObjectProps; + interval: string; +} + +export interface TooltipConfig { + position?: ToolTipPositions; + padding?: number | Padding; + centerOnMark?: boolean | number; +} + +export interface DstObj { + [index: string]: any; + type?: string; + latitude?: number; + longitude?: number; + zoom?: number; + mapStyle?: string | boolean; + minZoom?: number; + maxZoom?: number; + zoomControl?: boolean; + scrollWheelZoom?: boolean; + delayRepaint?: boolean; +} + +export type ControlsLocation = 'row' | 'column' | 'row-reverse' | 'column-reverse'; + +export type ControlsDirection = 'horizontal' | 'vertical'; + +export interface VegaConfig extends DstObj { + [index: string]: any; + maxBounds?: number; + tooltips?: TooltipConfig | boolean; + controlsLocation?: ControlsLocation; + controlsDirection?: ControlsDirection; +} + +export interface UrlParserConfig { + [index: string]: any; + elasticsearch: EsQueryParser; + emsfile: EmsFileParser; + url: UrlParser; +} + +export interface PendingType { + [index: string]: any; + dataObject?: Data; + obj?: Data; + url?: UrlObject; + name?: string; +} diff --git a/src/plugins/vis_type_vega/public/data_model/url_parser.js b/src/plugins/vis_type_vega/public/data_model/url_parser.ts similarity index 92% rename from src/plugins/vis_type_vega/public/data_model/url_parser.js rename to src/plugins/vis_type_vega/public/data_model/url_parser.ts index 9a30f12e08232..a27376bf25061 100644 --- a/src/plugins/vis_type_vega/public/data_model/url_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/url_parser.ts @@ -19,13 +19,15 @@ import $ from 'jquery'; import { i18n } from '@kbn/i18n'; +import { UrlObject } from './types'; /** * This class processes all Vega spec customizations, * converting url object parameters into query results. */ export class UrlParser { - constructor(onWarning) { + _onWarning: (...args: string[]) => void; + constructor(onWarning: (...args: string[]) => void) { this._onWarning = onWarning; } @@ -33,7 +35,7 @@ export class UrlParser { /** * Update request object */ - parseUrl(obj, urlObj) { + parseUrl(obj: UrlObject, urlObj: UrlObject) { let url = urlObj.url; if (!url) { throw new Error( diff --git a/src/plugins/vis_type_vega/public/data_model/utils.js b/src/plugins/vis_type_vega/public/data_model/utils.ts similarity index 75% rename from src/plugins/vis_type_vega/public/data_model/utils.js rename to src/plugins/vis_type_vega/public/data_model/utils.ts index 9cf5e36b81294..4d24b1237daeb 100644 --- a/src/plugins/vis_type_vega/public/data_model/utils.js +++ b/src/plugins/vis_type_vega/public/data_model/utils.ts @@ -23,13 +23,14 @@ export class Utils { /** * If the 2nd array parameter in args exists, append it to the warning/error string value */ - static formatWarningToStr(value) { - if (arguments.length >= 2) { + static formatWarningToStr(...args: any[]) { + let value = args[0]; + if (args.length >= 2) { try { - if (typeof arguments[1] === 'string') { - value += `\n${arguments[1]}`; + if (typeof args[1] === 'string') { + value += `\n${args[1]}`; } else { - value += '\n' + compactStringify(arguments[1], { maxLength: 70 }); + value += '\n' + compactStringify(args[1], { maxLength: 70 }); } } catch (err) { // ignore @@ -38,12 +39,13 @@ export class Utils { return value; } - static formatErrorToStr(error) { + static formatErrorToStr(...args: any[]) { + let error: Error | string = args[0]; if (!error) { error = 'ERR'; } else if (error instanceof Error) { error = error.message; } - return Utils.formatWarningToStr(error, ...Array.from(arguments).slice(1)); + return Utils.formatWarningToStr(error, ...Array.from(args).slice(1)); } } diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts similarity index 74% rename from src/plugins/vis_type_vega/public/data_model/vega_parser.js rename to src/plugins/vis_type_vega/public/data_model/vega_parser.ts index 377567e47ced8..17166e1540755 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -18,34 +18,78 @@ */ import _ from 'lodash'; -import { vega, vegaLite } from '../lib/vega'; import schemaParser from 'vega-schema-url-parser'; import versionCompare from 'compare-versions'; -import { EsQueryParser } from './es_query_parser'; import hjson from 'hjson'; +import { VISUALIZATION_COLORS } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +// @ts-ignore +import { vega, vegaLite } from '../lib/vega'; +import { EsQueryParser } from './es_query_parser'; import { Utils } from './utils'; import { EmsFileParser } from './ems_file_parser'; import { UrlParser } from './url_parser'; -import { VISUALIZATION_COLORS } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { SearchAPI } from './search_api'; +import { TimeCache } from './time_cache'; +import { IServiceSettings } from '../../../maps_legacy/public'; +import { + Bool, + Data, + VegaSpec, + VegaConfig, + TooltipConfig, + DstObj, + UrlParserConfig, + PendingType, + ControlsLocation, + ControlsDirection, + KibanaConfig, +} from './types'; // Set default single color to match other Kibana visualizations -const defaultColor = VISUALIZATION_COLORS[0]; -const locToDirMap = { +const defaultColor: string = VISUALIZATION_COLORS[0]; + +const locToDirMap: Record = { left: 'row-reverse', right: 'row', top: 'column-reverse', bottom: 'column', }; -const DEFAULT_SCHEMA = 'https://vega.github.io/schema/vega/v5.json'; +const DEFAULT_SCHEMA: string = 'https://vega.github.io/schema/vega/v5.json'; // If there is no "%type%" parameter, use this parser -const DEFAULT_PARSER = 'elasticsearch'; +const DEFAULT_PARSER: string = 'elasticsearch'; export class VegaParser { - constructor(spec, searchAPI, timeCache, filters, serviceSettings) { - this.spec = spec; + spec: VegaSpec; + hideWarnings: boolean; + error?: string; + warnings: string[]; + _urlParsers: UrlParserConfig; + isVegaLite?: boolean; + useHover?: boolean; + _config?: VegaConfig; + useMap?: boolean; + renderer?: string; + tooltips?: boolean | TooltipConfig; + mapConfig?: object; + vlspec?: VegaSpec; + useResize?: boolean; + paddingWidth?: number; + paddingHeight?: number; + containerDir?: ControlsLocation | ControlsDirection; + controlsDir?: ControlsLocation; + + constructor( + spec: VegaSpec | string, + searchAPI: SearchAPI, + timeCache: TimeCache, + filters: Bool, + serviceSettings: IServiceSettings + ) { + this.spec = spec as VegaSpec; this.hideWarnings = false; + this.error = undefined; this.warnings = []; @@ -90,10 +134,10 @@ export class VegaParser { this.tooltips = this._parseTooltips(); this._setDefaultColors(); - this._parseControlPlacement(this._config); + this._parseControlPlacement(); if (this.useMap) { this.mapConfig = this._parseMapConfig(); - } else if (this.spec.autosize === undefined) { + } else if (this.spec && this.spec.autosize === undefined) { // Default autosize should be fit, unless it's a map (leaflet-vega handles that) this.spec.autosize = { type: 'fit', contains: 'padding' }; } @@ -123,6 +167,7 @@ export class VegaParser { // This way we let leaflet-vega library inject a different default projection for tile maps. // Also, VL injects default padding and autosize values, but neither should be set for vega-leaflet. if (this.useMap) { + if (!this.spec || !this.vlspec) return; const hasConfig = _.isPlainObject(this.vlspec.config); if (this.vlspec.config === undefined || (hasConfig && !this.vlspec.config.projection)) { // Assume VL generates spec.projections = an array of exactly one object named 'projection' @@ -168,49 +213,52 @@ export class VegaParser { */ _calcSizing() { this.useResize = false; - if (!this.useMap) { - // when useResize is true, vega's canvas size will be set based on the size of the container, - // and will be automatically updated on resize events. - // We delete width & height if the autosize is set to "fit" - // We also set useResize=true in case autosize=none, and width & height are not set - const autosize = this.spec.autosize.type || this.spec.autosize; - if (autosize === 'fit' || (autosize === 'none' && !this.spec.width && !this.spec.height)) { - this.useResize = true; - } - } // Padding is not included in the width/height by default this.paddingWidth = 0; this.paddingHeight = 0; - if (this.useResize && this.spec.padding && this.spec.autosize.contains !== 'padding') { - if (typeof this.spec.padding === 'object') { - this.paddingWidth += (+this.spec.padding.left || 0) + (+this.spec.padding.right || 0); - this.paddingHeight += (+this.spec.padding.top || 0) + (+this.spec.padding.bottom || 0); - } else { - this.paddingWidth += 2 * (+this.spec.padding || 0); - this.paddingHeight += 2 * (+this.spec.padding || 0); + if (this.spec) { + if (!this.useMap) { + // when useResize is true, vega's canvas size will be set based on the size of the container, + // and will be automatically updated on resize events. + // We delete width & height if the autosize is set to "fit" + // We also set useResize=true in case autosize=none, and width & height are not set + const autosize = this.spec.autosize.type || this.spec.autosize; + if (autosize === 'fit' || (autosize === 'none' && !this.spec.width && !this.spec.height)) { + this.useResize = true; + } } - } - if (this.useResize && (this.spec.width || this.spec.height)) { - if (this.isVegaLite) { - delete this.spec.width; - delete this.spec.height; - } else { - this._onWarning( - i18n.translate( - 'visTypeVega.vegaParser.widthAndHeightParamsAreIgnoredWithAutosizeFitWarningMessage', - { - defaultMessage: - 'The {widthParam} and {heightParam} params are ignored with {autosizeParam}', - values: { - autosizeParam: 'autosize=fit', - widthParam: '"width"', - heightParam: '"height"', - }, - } - ) - ); + if (this.useResize && this.spec.padding && this.spec.autosize.contains !== 'padding') { + if (typeof this.spec.padding === 'object') { + this.paddingWidth += (+this.spec.padding.left || 0) + (+this.spec.padding.right || 0); + this.paddingHeight += (+this.spec.padding.top || 0) + (+this.spec.padding.bottom || 0); + } else { + this.paddingWidth += 2 * (+this.spec.padding || 0); + this.paddingHeight += 2 * (+this.spec.padding || 0); + } + } + + if (this.useResize && (this.spec.width || this.spec.height)) { + if (this.isVegaLite) { + delete this.spec.width; + delete this.spec.height; + } else { + this._onWarning( + i18n.translate( + 'visTypeVega.vegaParser.widthAndHeightParamsAreIgnoredWithAutosizeFitWarningMessage', + { + defaultMessage: + 'The {widthParam} and {heightParam} params are ignored with {autosizeParam}', + values: { + autosizeParam: 'autosize=fit', + widthParam: '"width"', + heightParam: '"height"', + }, + } + ) + ); + } } } } @@ -220,9 +268,11 @@ export class VegaParser { * @private */ _parseControlPlacement() { - this.containerDir = locToDirMap[this._config.controlsLocation]; + this.containerDir = this._config?.controlsLocation + ? locToDirMap[this._config.controlsLocation] + : undefined; if (this.containerDir === undefined) { - if (this._config.controlsLocation === undefined) { + if (this._config && this._config.controlsLocation === undefined) { this.containerDir = 'column'; } else { throw new Error( @@ -230,14 +280,14 @@ export class VegaParser { defaultMessage: 'Unrecognized {controlsLocationParam} value. Expecting one of [{locToDirMap}]', values: { - locToDirMap: `"${locToDirMap.keys().join('", "')}"`, + locToDirMap: `"${Object.keys(locToDirMap).join('", "')}"`, controlsLocationParam: 'controlsLocation', }, }) ); } } - const dir = this._config.controlsDirection; + const dir = this._config?.controlsDirection; if (dir !== undefined && dir !== 'horizontal' && dir !== 'vertical') { throw new Error( i18n.translate('visTypeVega.vegaParser.unrecognizedDirValueErrorMessage', { @@ -254,51 +304,53 @@ export class VegaParser { * @returns {object} kibana config * @private */ - _parseConfig() { - let result; - if (this.spec._hostConfig !== undefined) { - result = this.spec._hostConfig; - delete this.spec._hostConfig; - if (!_.isPlainObject(result)) { - throw new Error( - i18n.translate('visTypeVega.vegaParser.hostConfigValueTypeErrorMessage', { - defaultMessage: 'If present, {configName} must be an object', - values: { configName: '"_hostConfig"' }, + _parseConfig(): KibanaConfig | {} { + let result: KibanaConfig | null = null; + if (this.spec) { + if (this.spec._hostConfig !== undefined) { + result = this.spec._hostConfig; + delete this.spec._hostConfig; + if (!_.isPlainObject(result)) { + throw new Error( + i18n.translate('visTypeVega.vegaParser.hostConfigValueTypeErrorMessage', { + defaultMessage: 'If present, {configName} must be an object', + values: { configName: '"_hostConfig"' }, + }) + ); + } + this._onWarning( + i18n.translate('visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage', { + defaultMessage: + '{deprecatedConfigName} has been deprecated. Use {newConfigName} instead.', + values: { + deprecatedConfigName: '"_hostConfig"', + newConfigName: 'config.kibana', + }, }) ); } - this._onWarning( - i18n.translate('visTypeVega.vegaParser.hostConfigIsDeprecatedWarningMessage', { - defaultMessage: - '{deprecatedConfigName} has been deprecated. Use {newConfigName} instead.', - values: { - deprecatedConfigName: '"_hostConfig"', - newConfigName: 'config.kibana', - }, - }) - ); - } - if (_.isPlainObject(this.spec.config) && this.spec.config.kibana !== undefined) { - result = this.spec.config.kibana; - delete this.spec.config.kibana; - if (!_.isPlainObject(result)) { - throw new Error( - i18n.translate('visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage', { - defaultMessage: 'If present, {configName} must be an object', - values: { configName: 'config.kibana' }, - }) - ); + if (_.isPlainObject(this.spec.config) && this.spec.config.kibana !== undefined) { + result = this.spec.config.kibana; + delete this.spec.config.kibana; + if (!_.isPlainObject(result)) { + throw new Error( + i18n.translate('visTypeVega.vegaParser.kibanaConfigValueTypeErrorMessage', { + defaultMessage: 'If present, {configName} must be an object', + values: { configName: 'config.kibana' }, + }) + ); + } } } return result || {}; } _parseTooltips() { - if (this._config.tooltips === false) { + if (this._config && this._config.tooltips === false) { return false; } - const result = this._config.tooltips || {}; + const result: TooltipConfig = (this._config?.tooltips as TooltipConfig) || {}; if (result.position === undefined) { result.position = 'top'; @@ -352,12 +404,12 @@ export class VegaParser { * @private */ _parseMapConfig() { - const res = { - delayRepaint: this._config.delayRepaint === undefined ? true : this._config.delayRepaint, + const res: VegaConfig = { + delayRepaint: this._config?.delayRepaint === undefined ? true : this._config.delayRepaint, }; - const validate = (name, isZoom) => { - const val = this._config[name]; + const validate = (name: string, isZoom: boolean) => { + const val = this._config ? this._config[name] : undefined; if (val !== undefined) { const parsed = parseFloat(val); if (Number.isFinite(parsed) && (!isZoom || (parsed >= 0 && parsed <= 30))) { @@ -381,7 +433,7 @@ export class VegaParser { validate(`maxZoom`, true); // `false` is a valid value - res.mapStyle = this._config.mapStyle === undefined ? `default` : this._config.mapStyle; + res.mapStyle = this._config?.mapStyle === undefined ? `default` : this._config.mapStyle; if (res.mapStyle !== `default` && res.mapStyle !== false) { this._onWarning( i18n.translate('visTypeVega.vegaParser.mapStyleValueTypeWarningMessage', { @@ -400,7 +452,7 @@ export class VegaParser { this._parseBool('zoomControl', res, true); this._parseBool('scrollWheelZoom', res, false); - const maxBounds = this._config.maxBounds; + const maxBounds = this._config?.maxBounds; if (maxBounds !== undefined) { if ( !Array.isArray(maxBounds) || @@ -423,8 +475,8 @@ export class VegaParser { return res; } - _parseBool(paramName, dstObj, dflt) { - const val = this._config[paramName]; + _parseBool(paramName: string, dstObj: DstObj, dflt: boolean | string | number) { + const val = this._config ? this._config[paramName] : undefined; if (val === undefined) { dstObj[paramName] = dflt; } else if (typeof val !== 'boolean') { @@ -448,6 +500,7 @@ export class VegaParser { * @private */ _parseSchema() { + if (!this.spec) return false; if (!this.spec.$schema) { this._onWarning( i18n.translate('visTypeVega.vegaParser.inputSpecDoesNotSpecifySchemaWarningMessage', { @@ -486,13 +539,13 @@ export class VegaParser { * @private */ async _resolveDataUrls() { - const pending = {}; + const pending: PendingType = {}; - this._findObjectDataUrls(this.spec, (obj) => { + this._findObjectDataUrls(this.spec!, (obj: Data) => { const url = obj.url; delete obj.url; - let type = url['%type%']; - delete url['%type%']; + let type = url!['%type%']; + delete url!['%type%']; if (type === undefined) { type = DEFAULT_PARSER; } @@ -533,7 +586,8 @@ export class VegaParser { * @param {string} [key] field name of the current object * @private */ - _findObjectDataUrls(obj, onFind, key) { + + _findObjectDataUrls(obj: VegaSpec | Data, onFind: (data: Data) => void, key?: unknown) { if (Array.isArray(obj)) { for (const elem of obj) { this._findObjectDataUrls(elem, onFind, key); @@ -557,7 +611,7 @@ export class VegaParser { ) ); } - onFind(obj); + onFind(obj as Data); } else { for (const k of Object.keys(obj)) { this._findObjectDataUrls(obj[k], onFind, k); @@ -582,7 +636,7 @@ export class VegaParser { // https://github.com/vega/vega/issues/1083 // Don't set defaults if spec.config.mark.color or fill are set if ( - !this.spec.config.mark || + !this.spec?.config.mark || (this.spec.config.mark.color === undefined && this.spec.config.mark.fill === undefined) ) { this._setDefaultValue(defaultColor, 'config', 'arc', 'fill'); @@ -605,7 +659,7 @@ export class VegaParser { * @param {string} fields * @private */ - _setDefaultValue(value, ...fields) { + _setDefaultValue(value: unknown, ...fields: string[]) { let o = this.spec; for (let i = 0; i < fields.length - 1; i++) { const field = fields[i]; @@ -627,9 +681,10 @@ export class VegaParser { * Add a warning to the warnings array * @private */ - _onWarning() { + _onWarning(...args: any[]) { if (!this.hideWarnings) { - this.warnings.push(Utils.formatWarningToStr(...arguments)); + this.warnings.push(Utils.formatWarningToStr(args)); + return Utils.formatWarningToStr(args); } } } diff --git a/src/plugins/vis_type_vega/public/vega_fn.ts b/src/plugins/vis_type_vega/public/vega_fn.ts index 6b1af6044a2c4..d077aa7aee004 100644 --- a/src/plugins/vis_type_vega/public/vega_fn.ts +++ b/src/plugins/vis_type_vega/public/vega_fn.ts @@ -23,6 +23,7 @@ import { ExpressionFunctionDefinition, KibanaContext, Render } from '../../expre import { VegaVisualizationDependencies } from './plugin'; import { createVegaRequestHandler } from './vega_request_handler'; import { TimeRange, Query } from '../../data/public'; +import { VegaParser } from './data_model/vega_parser'; type Input = KibanaContext | null; type Output = Promise>; @@ -34,7 +35,7 @@ interface Arguments { export type VisParams = Required; interface RenderValue { - visData: Input; + visData: VegaParser; visType: 'vega'; visConfig: VisParams; } diff --git a/src/plugins/vis_type_vega/public/vega_request_handler.ts b/src/plugins/vis_type_vega/public/vega_request_handler.ts index ac28f0b3782b2..997b1982d749a 100644 --- a/src/plugins/vis_type_vega/public/vega_request_handler.ts +++ b/src/plugins/vis_type_vega/public/vega_request_handler.ts @@ -20,8 +20,6 @@ import { Filter, esQuery, TimeRange, Query } from '../../data/public'; import { SearchAPI } from './data_model/search_api'; - -// @ts-ignore import { TimeCache } from './data_model/time_cache'; import { VegaVisualizationDependencies } from './plugin'; @@ -64,7 +62,6 @@ export function createVegaRequestHandler( const esQueryConfigs = esQuery.getEsQueryConfig(uiSettings); const filtersDsl = esQuery.buildEsQuery(undefined, query, filters, esQueryConfigs); - // @ts-ignore const { VegaParser } = await import('./data_model/vega_parser'); const vp = new VegaParser(visParams.spec, searchAPI, timeCache, filtersDsl, serviceSettings); diff --git a/yarn.lock b/yarn.lock index 0f144078ff46f..8e04560bd303e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5163,6 +5163,11 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.3.tgz#856c99cdc1551d22c22b18b5402719affec9839a" integrity sha512-cS5owqtwzLN5kY+l+KgKdRJ/Cee8tlmQoGQuIE9tWnSmS3JMKzmxo2HIAk2wODMifGwO20d62xZQLYz+RLfXmw== +"@types/hjson@^2.4.2": + version "2.4.2" + resolved "https://registry.yarnpkg.com/@types/hjson/-/hjson-2.4.2.tgz#fd0288a5b6778cda993c978e43cc978ddc8f22e9" + integrity sha512-MSKTfEyR8DbzJTOAY47BIJBD72ol4cu6BOw5inda0q1eEtEmurVHL4OmYB3Lxa4/DwXbWidkddvtoygbGQEDIw== + "@types/hoek@^4.1.3": version "4.1.3" resolved "https://registry.yarnpkg.com/@types/hoek/-/hoek-4.1.3.tgz#d1982d48fb0d2a0e5d7e9d91838264d8e428d337" From ed387dd15fce7fbfc64104839c03e57ef66e3756 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Wed, 15 Jul 2020 09:36:48 -0400 Subject: [PATCH 06/52] add policy details and update SO limit requests (#71789) --- .../server/usage/collector.ts | 4 +- .../server/usage/endpoints/endpoint.mocks.ts | 103 ++++++++++++ .../server/usage/endpoints/endpoint.test.ts | 36 +++- .../usage/endpoints/fleet_saved_objects.ts | 4 +- .../server/usage/endpoints/index.ts | 154 +++++++++++++----- .../schema/xpack_plugins.json | 4 +- 6 files changed, 252 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index bb3583d50f8e5..9740f57450e80 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -66,8 +66,8 @@ export const registerCollector: RegisterCollector = ({ }, policies: { malware: { - success: { type: 'long' }, - warning: { type: 'long' }, + active: { type: 'long' }, + inactive: { type: 'long' }, failure: { type: 'long' }, }, }, diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts index f41cfb773736d..1369a3d398265 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts @@ -76,6 +76,108 @@ export const mockFleetObjectsResponse = ( ], }); +const mockPolicyPayload = (malwareStatus: 'success' | 'warning' | 'failure') => + JSON.stringify({ + 'endpoint-security': { + Endpoint: { + configuration: { + inputs: [ + { + id: '0d466df0-c60f-11ea-a5c5-151665e785c4', + policy: { + linux: { + events: { + file: true, + network: true, + process: true, + }, + logging: { + file: 'info', + }, + }, + mac: { + events: { + file: true, + network: true, + process: true, + }, + logging: { + file: 'info', + }, + malware: { + mode: 'prevent', + }, + }, + windows: { + events: { + dll_and_driver_load: true, + dns: true, + file: true, + network: true, + process: true, + registry: true, + security: true, + }, + logging: { + file: 'info', + }, + malware: { + mode: 'prevent', + }, + }, + }, + }, + ], + }, + policy: { + applied: { + id: '0d466df0-c60f-11ea-a5c5-151665e785c4', + response: { + configurations: { + malware: { + concerned_actions: [ + 'load_config', + 'workflow', + 'download_global_artifacts', + 'download_user_artifacts', + 'configure_malware', + 'read_malware_config', + 'load_malware_model', + 'read_kernel_config', + 'configure_kernel', + 'detect_process_events', + 'detect_file_write_events', + 'connect_kernel', + 'detect_file_open_events', + 'detect_sync_image_load_events', + ], + status: `${malwareStatus}`, + }, + }, + }, + status: `${malwareStatus}`, + }, + }, + }, + agent: { + id: 'testAgentId', + version: '8.0.0-SNAPSHOT', + }, + host: { + architecture: 'x86_64', + id: 'a4148b63-1758-ab1f-a6d3-f95075cb1a9c', + os: { + Ext: { + variant: 'Windows 10 Pro', + }, + full: 'Windows 10 Pro 2004 (10.0.19041.329)', + name: 'Windows', + version: '2004 (10.0.19041.329)', + }, + }, + }, + }); + /** * * @param running - allows us to set whether the mocked endpoint is in an active or disabled/failed state @@ -102,6 +204,7 @@ export const mockFleetEventsObjectsResponse = ( message: `Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to ${ running ? 'RUNNING' : 'FAILED' }: `, + payload: mockPolicyPayload(running ? 'success' : 'failure'), config_id: testConfigId, }, references: [], diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts index 0b2f4e4ed9dbe..06755192bd818 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts @@ -20,12 +20,12 @@ import * as fleetSavedObjects from './fleet_saved_objects'; describe('test security solution endpoint telemetry', () => { let mockSavedObjectsRepository: jest.Mocked; let getFleetSavedObjectsMetadataSpy: jest.SpyInstance>>; - let getFleetEventsSavedObjectsSpy: jest.SpyInstance >>; beforeAll(() => { - getFleetEventsSavedObjectsSpy = jest.spyOn(fleetSavedObjects, 'getFleetEventsSavedObjects'); + getLatestFleetEndpointEventSpy = jest.spyOn(fleetSavedObjects, 'getLatestFleetEndpointEvent'); getFleetSavedObjectsMetadataSpy = jest.spyOn(fleetSavedObjects, 'getFleetSavedObjectsMetadata'); mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); }); @@ -39,6 +39,13 @@ describe('test security solution endpoint telemetry', () => { Object { "active_within_last_24_hours": 0, "os": Array [], + "policies": Object { + "malware": Object { + "active": 0, + "failure": 0, + "inactive": 0, + }, + }, "total_installed": 0, } `); @@ -58,6 +65,13 @@ describe('test security solution endpoint telemetry', () => { total_installed: 0, active_within_last_24_hours: 0, os: [], + policies: { + malware: { + failure: 0, + active: 0, + inactive: 0, + }, + }, }); }); }); @@ -67,7 +81,7 @@ describe('test security solution endpoint telemetry', () => { getFleetSavedObjectsMetadataSpy.mockImplementation(() => Promise.resolve(mockFleetObjectsResponse()) ); - getFleetEventsSavedObjectsSpy.mockImplementation(() => + getLatestFleetEndpointEventSpy.mockImplementation(() => Promise.resolve(mockFleetEventsObjectsResponse()) ); @@ -85,6 +99,13 @@ describe('test security solution endpoint telemetry', () => { count: 1, }, ], + policies: { + malware: { + failure: 1, + active: 0, + inactive: 0, + }, + }, }); }); @@ -92,7 +113,7 @@ describe('test security solution endpoint telemetry', () => { getFleetSavedObjectsMetadataSpy.mockImplementation(() => Promise.resolve(mockFleetObjectsResponse()) ); - getFleetEventsSavedObjectsSpy.mockImplementation(() => + getLatestFleetEndpointEventSpy.mockImplementation(() => Promise.resolve(mockFleetEventsObjectsResponse(true)) ); @@ -110,6 +131,13 @@ describe('test security solution endpoint telemetry', () => { count: 1, }, ], + policies: { + malware: { + failure: 0, + active: 1, + inactive: 0, + }, + }, }); }); }); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts index 70657ed9f08f7..7e05fdec36169 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts @@ -19,17 +19,19 @@ export const getFleetSavedObjectsMetadata = async (savedObjectsClient: ISavedObj type: AGENT_SAVED_OBJECT_TYPE, fields: ['packages', 'last_checkin', 'local_metadata'], filter: `${AGENT_SAVED_OBJECT_TYPE}.attributes.packages: ${FLEET_ENDPOINT_PACKAGE_CONSTANT}`, + perPage: 10000, sortField: 'enrolled_at', sortOrder: 'desc', }); -export const getFleetEventsSavedObjects = async ( +export const getLatestFleetEndpointEvent = async ( savedObjectsClient: ISavedObjectsRepository, agentId: string ) => savedObjectsClient.find({ type: AGENT_EVENT_SAVED_OBJECT_TYPE, filter: `${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.agent_id: ${agentId} and ${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.message: "${FLEET_ENDPOINT_PACKAGE_CONSTANT}"`, + perPage: 1, // Get the most recent endpoint event. sortField: 'timestamp', sortOrder: 'desc', search: agentId, diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts index 576d248613d1e..ab5669d503275 100644 --- a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts +++ b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts @@ -6,11 +6,7 @@ import { ISavedObjectsRepository } from 'src/core/server'; import { AgentMetadata } from '../../../../ingest_manager/common/types/models/agent'; -import { - getFleetSavedObjectsMetadata, - getFleetEventsSavedObjects, - FLEET_ENDPOINT_PACKAGE_CONSTANT, -} from './fleet_saved_objects'; +import { getFleetSavedObjectsMetadata, getLatestFleetEndpointEvent } from './fleet_saved_objects'; export interface AgentOSMetadataTelemetry { full_name: string; @@ -18,22 +14,25 @@ export interface AgentOSMetadataTelemetry { version: string; count: number; } +export interface PolicyTelemetry { + active: number; + inactive: number; + failure: number; +} export interface PoliciesTelemetry { - malware: { - success: number; - warning: number; - failure: number; - }; + malware: PolicyTelemetry; } export interface EndpointUsage { total_installed: number; active_within_last_24_hours: number; os: AgentOSMetadataTelemetry[]; - policies?: PoliciesTelemetry; // TODO: make required when able to enable policy information + policies: PoliciesTelemetry; } +type EndpointOSNames = 'Linux' | 'Windows' | 'macOs'; + export interface AgentLocalMetadata extends AgentMetadata { elastic: { agent: { @@ -51,7 +50,8 @@ export interface AgentLocalMetadata extends AgentMetadata { }; } -export type OSTracker = Record; +type OSTracker = Record; +type AgentDailyActiveTracker = Map; /** * @description returns an empty telemetry object to be incrmented and updated within the `getEndpointTelemetryFromFleet` fn */ @@ -59,8 +59,18 @@ export const getDefaultEndpointTelemetry = (): EndpointUsage => ({ total_installed: 0, active_within_last_24_hours: 0, os: [], + policies: { + malware: { + active: 0, + inactive: 0, + failure: 0, + }, + }, }); +/** + * @description this fun + */ export const trackEndpointOSTelemetry = ( os: AgentLocalMetadata['os'], osTracker: OSTracker @@ -82,6 +92,80 @@ export const trackEndpointOSTelemetry = ( return updatedOSTracker; }; +/** + * @description This iterates over all unique agents that currently track an endpoint package. It takes a list of agents who have checked in in the last 24 hours + * and then checks whether those agents have endpoints whose latest status is 'RUNNING' to determine an active_within_last_24_hours. Since the policy information is also tracked in these events + * we pull out the status of the current protection (malware) type. This must be done in a compound manner as the desired status is reflected in the config, and the successful application of that policy + * is tracked in the policy.applied.response.configurations[protectionsType].status. Using these two we can determine whether the policy is toggled on, off, or failed to turn on. + */ +export const addEndpointDailyActivityAndPolicyDetailsToTelemetry = async ( + agentDailyActiveTracker: AgentDailyActiveTracker, + savedObjectsClient: ISavedObjectsRepository, + endpointTelemetry: EndpointUsage +): Promise => { + const updatedEndpointTelemetry = { ...endpointTelemetry }; + + const policyHostTypeToPolicyType = { + Linux: 'linux', + macOs: 'mac', + Windows: 'windows', + }; + const enabledMalwarePolicyTypes = ['prevent', 'detect']; + + for (const agentId of agentDailyActiveTracker.keys()) { + const { saved_objects: agentEvents } = await getLatestFleetEndpointEvent( + savedObjectsClient, + agentId + ); + + const latestEndpointEvent = agentEvents[0]; + if (latestEndpointEvent) { + /* + We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours + then the endpoint has still been running within the last 24 hours. + */ + const { subtype, payload } = latestEndpointEvent.attributes; + const endpointIsActive = + subtype === 'RUNNING' && agentDailyActiveTracker.get(agentId) === true; + + if (endpointIsActive) { + updatedEndpointTelemetry.active_within_last_24_hours += 1; + } + + // The policy details are sent as a string on the 'payload' attribute of the agent event + const endpointPolicyDetails = payload ? JSON.parse(payload) : null; + if (endpointPolicyDetails) { + // We get the setting the user desired to enable (treating prevent and detect as 'active' states) and then see if it succeded or failed. + const hostType = + policyHostTypeToPolicyType[ + endpointPolicyDetails['endpoint-security']?.host?.os?.name as EndpointOSNames + ]; + const userDesiredMalwareState = + endpointPolicyDetails['endpoint-security'].Endpoint?.configuration?.inputs[0]?.policy[ + hostType + ]?.malware?.mode; + + const isAnActiveMalwareState = enabledMalwarePolicyTypes.includes(userDesiredMalwareState); + const malwareStatus = + endpointPolicyDetails['endpoint-security'].Endpoint?.policy?.applied?.response + ?.configurations?.malware?.status; + + if (isAnActiveMalwareState && malwareStatus !== 'failure') { + updatedEndpointTelemetry.policies.malware.active += 1; + } + if (!isAnActiveMalwareState) { + updatedEndpointTelemetry.policies.malware.inactive += 1; + } + if (isAnActiveMalwareState && malwareStatus === 'failure') { + updatedEndpointTelemetry.policies.malware.failure += 1; + } + } + } + } + + return updatedEndpointTelemetry; +}; + /** * @description This aggregates the telemetry details from the two fleet savedObject sources, `fleet-agents` and `fleet-agent-events` to populate * the telemetry details for endpoint. Since we cannot access our own indices due to `kibana_system` not having access, this is the best alternative. @@ -100,8 +184,8 @@ export const getEndpointTelemetryFromFleet = async ( // Use unique hosts to prevent any potential duplicates const uniqueHostIds: Set = new Set(); - // Need unique agents to get events data for those that have run in last 24 hours - const uniqueAgentIds: Set = new Set(); + // Need agents to get events data for those that have run in last 24 hours as well as policy details + const agentDailyActiveTracker: AgentDailyActiveTracker = new Map(); const aDayAgo = new Date(); aDayAgo.setDate(aDayAgo.getDate() - 1); @@ -110,17 +194,15 @@ export const getEndpointTelemetryFromFleet = async ( const endpointMetadataTelemetry = endpointAgents.reduce( (metadataTelemetry, { attributes: metadataAttributes }) => { const { last_checkin: lastCheckin, local_metadata: localMetadata } = metadataAttributes; - // The extended AgentMetadata is just an empty blob, so cast to account for our specific use case - const { host, os, elastic } = localMetadata as AgentLocalMetadata; + const { host, os, elastic } = localMetadata as AgentLocalMetadata; // AgentMetadata is just an empty blob, casting for our use case - if (lastCheckin && new Date(lastCheckin) > aDayAgo) { - // Get agents that have checked in within the last 24 hours to later see if their endpoints are running - uniqueAgentIds.add(elastic.agent.id); - } if (host && uniqueHostIds.has(host.id)) { + // use hosts since new agents could potentially be re-installed on existing hosts return metadataTelemetry; } else { uniqueHostIds.add(host.id); + const isActiveWithinLastDay = !!lastCheckin && new Date(lastCheckin) > aDayAgo; + agentDailyActiveTracker.set(elastic.agent.id, isActiveWithinLastDay); osTracker = trackEndpointOSTelemetry(os, osTracker); return metadataTelemetry; } @@ -128,32 +210,16 @@ export const getEndpointTelemetryFromFleet = async ( endpointTelemetry ); - // All unique agents with an endpoint installed. You can technically install a new agent on a host, so relying on most recently installed. + // All unique hosts with an endpoint installed. endpointTelemetry.total_installed = uniqueHostIds.size; - // Get the objects to populate our OS Telemetry endpointMetadataTelemetry.os = Object.values(osTracker); + // Populate endpoint telemetry with the finalized 24 hour count and policy details + const finalizedEndpointTelemetryData = await addEndpointDailyActivityAndPolicyDetailsToTelemetry( + agentDailyActiveTracker, + savedObjectsClient, + endpointMetadataTelemetry + ); - // Check for agents running in the last 24 hours whose endpoints are still active - for (const agentId of uniqueAgentIds) { - const { saved_objects: agentEvents } = await getFleetEventsSavedObjects( - savedObjectsClient, - agentId - ); - const lastEndpointStatus = agentEvents.find((agentEvent) => - agentEvent.attributes.message.includes(FLEET_ENDPOINT_PACKAGE_CONSTANT) - ); - - /* - We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours - then the endpoint has still been running within the last 24 hours. If / when we get the policy response, then we can use that - instead - */ - const endpointIsActive = lastEndpointStatus?.attributes.subtype === 'RUNNING'; - if (endpointIsActive) { - endpointMetadataTelemetry.active_within_last_24_hours += 1; - } - } - - return endpointMetadataTelemetry; + return finalizedEndpointTelemetryData; }; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index a7bc29f9efae2..fd21b70660bb6 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -246,10 +246,10 @@ "properties": { "malware": { "properties": { - "success": { + "active": { "type": "long" }, - "warning": { + "inactive": { "type": "long" }, "failure": { From 8bcecc0fb01edbb6a64fad239c29ccd4d2555083 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Wed, 15 Jul 2020 08:45:20 -0500 Subject: [PATCH 07/52] [logging] Format new platform json logging to ECS (#71138) * [logging] Format new platform json logging to ECS * update integration tests * merge instead of assign * add @timestamp override test * add partial merge test against log object * add object level override test * fix type error Co-authored-by: Elastic Machine --- .../__snapshots__/logging_system.test.ts.snap | 54 ++++--- .../logging/integration_tests/logging.test.ts | 30 ++-- .../__snapshots__/json_layout.test.ts.snap | 12 +- .../logging/layouts/json_layout.test.ts | 133 +++++++++++++++--- .../server/logging/layouts/json_layout.ts | 31 ++-- .../server/logging/logging_system.test.ts | 50 ++++--- 6 files changed, 231 insertions(+), 79 deletions(-) diff --git a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap index 2add00457b2ed..cbe0e352a0f3a 100644 --- a/src/core/server/logging/__snapshots__/logging_system.test.ts.snap +++ b/src/core/server/logging/__snapshots__/logging_system.test.ts.snap @@ -15,56 +15,72 @@ exports[`appends records via multiple appenders.: file logs 2`] = ` exports[`asLoggerFactory() only allows to create new loggers. 1`] = ` Object { "@timestamp": "2012-01-31T18:33:22.011-05:00", - "context": "test.context", - "level": "TRACE", + "log": Object { + "level": "TRACE", + "logger": "test.context", + }, "message": "buffered trace message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; exports[`asLoggerFactory() only allows to create new loggers. 2`] = ` Object { "@timestamp": "2012-01-31T13:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "buffered info message", - "meta": Object { - "some": "value", + "process": Object { + "pid": Any, }, - "pid": Any, + "some": "value", } `; exports[`asLoggerFactory() only allows to create new loggers. 3`] = ` Object { "@timestamp": "2012-01-31T08:33:22.011-05:00", - "context": "test.context", - "level": "FATAL", + "log": Object { + "level": "FATAL", + "logger": "test.context", + }, "message": "buffered fatal message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; exports[`flushes memory buffer logger and switches to real logger once config is provided: buffered messages 1`] = ` Object { "@timestamp": "2012-02-01T09:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "buffered info message", - "meta": Object { - "some": "value", + "process": Object { + "pid": Any, }, - "pid": Any, + "some": "value", } `; exports[`flushes memory buffer logger and switches to real logger once config is provided: new messages 1`] = ` Object { "@timestamp": "2012-01-31T23:33:22.011-05:00", - "context": "test.context", - "level": "INFO", + "log": Object { + "level": "INFO", + "logger": "test.context", + }, "message": "some new info message", - "pid": Any, + "process": Object { + "pid": Any, + }, } `; diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index a80939a25ae65..841c1ce15af47 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -198,13 +198,17 @@ describe('logging service', () => { JSON.parse(jsonString) ); expect(firstCall).toMatchObject({ - level: 'DEBUG', - context: 'plugins.myplugin.debug_json', + log: { + level: 'DEBUG', + logger: 'plugins.myplugin.debug_json', + }, message: 'log1', }); expect(secondCall).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.debug_json', + log: { + level: 'INFO', + logger: 'plugins.myplugin.debug_json', + }, message: 'log2', }); }); @@ -217,8 +221,10 @@ describe('logging service', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.info_json', + log: { + level: 'INFO', + logger: 'plugins.myplugin.info_json', + }, message: 'log2', }); }); @@ -259,14 +265,18 @@ describe('logging service', () => { const logs = mockConsoleLog.mock.calls.map(([jsonString]) => jsonString); expect(JSON.parse(logs[0])).toMatchObject({ - level: 'DEBUG', - context: 'plugins.myplugin.all', + log: { + level: 'DEBUG', + logger: 'plugins.myplugin.all', + }, message: 'log1', }); expect(logs[1]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][DEBUG] log1'); expect(JSON.parse(logs[2])).toMatchObject({ - level: 'INFO', - context: 'plugins.myplugin.all', + log: { + level: 'INFO', + logger: 'plugins.myplugin.all', + }, message: 'log2', }); expect(logs[3]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][INFO ] log2'); diff --git a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap index 14c071b40ad7a..0e7ce8d0b2f3c 100644 --- a/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap +++ b/src/core/server/logging/layouts/__snapshots__/json_layout.test.ts.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"name\\":\\"Some error name\\",\\"stack\\":\\"Some error stack\\"},\\"level\\":\\"FATAL\\",\\"message\\":\\"message-1\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 1`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-1\\",\\"error\\":{\\"message\\":\\"Some error message\\",\\"type\\":\\"Some error name\\",\\"stack_trace\\":\\"Some error stack\\"},\\"log\\":{\\"level\\":\\"FATAL\\",\\"logger\\":\\"context-1\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-2\\",\\"level\\":\\"ERROR\\",\\"message\\":\\"message-2\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 2`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-2\\",\\"log\\":{\\"level\\":\\"ERROR\\",\\"logger\\":\\"context-2\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-3\\",\\"level\\":\\"WARN\\",\\"message\\":\\"message-3\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 3`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-3\\",\\"log\\":{\\"level\\":\\"WARN\\",\\"logger\\":\\"context-3\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-4\\",\\"level\\":\\"DEBUG\\",\\"message\\":\\"message-4\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 4`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-4\\",\\"log\\":{\\"level\\":\\"DEBUG\\",\\"logger\\":\\"context-4\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-5\\",\\"level\\":\\"INFO\\",\\"message\\":\\"message-5\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 5`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-5\\",\\"log\\":{\\"level\\":\\"INFO\\",\\"logger\\":\\"context-5\\"},\\"process\\":{\\"pid\\":5355}}"`; -exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"context\\":\\"context-6\\",\\"level\\":\\"TRACE\\",\\"message\\":\\"message-6\\",\\"pid\\":5355}"`; +exports[`\`format()\` correctly formats record. 6`] = `"{\\"@timestamp\\":\\"2012-02-01T09:30:22.011-05:00\\",\\"message\\":\\"message-6\\",\\"log\\":{\\"level\\":\\"TRACE\\",\\"logger\\":\\"context-6\\"},\\"process\\":{\\"pid\\":5355}}"`; diff --git a/src/core/server/logging/layouts/json_layout.test.ts b/src/core/server/logging/layouts/json_layout.test.ts index 77e2876c143da..6cda1e4806aa8 100644 --- a/src/core/server/logging/layouts/json_layout.test.ts +++ b/src/core/server/logging/layouts/json_layout.test.ts @@ -98,21 +98,27 @@ test('`format()` correctly formats record with meta-data', () => { timestamp, pid: 5355, meta: { - from: 'v7', - to: 'v8', + version: { + from: 'v7', + to: 'v8', + }, }, }) ) ).toStrictEqual({ '@timestamp': '2012-02-01T09:30:22.011-05:00', - context: 'context-with-meta', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'context-with-meta', + }, message: 'message-with-meta', - meta: { + version: { from: 'v7', to: 'v8', }, - pid: 5355, + process: { + pid: 5355, + }, }); }); @@ -122,36 +128,131 @@ test('`format()` correctly formats error record with meta-data', () => { expect( JSON.parse( layout.format({ - context: 'error-with-meta', level: LogLevel.Debug, + context: 'error-with-meta', error: { message: 'Some error message', - name: 'Some error name', + name: 'Some error type', stack: 'Some error stack', }, message: 'Some error message', timestamp, pid: 5355, meta: { - from: 'v7', - to: 'v8', + version: { + from: 'v7', + to: 'v8', + }, }, }) ) ).toStrictEqual({ '@timestamp': '2012-02-01T09:30:22.011-05:00', - context: 'error-with-meta', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'error-with-meta', + }, error: { message: 'Some error message', - name: 'Some error name', - stack: 'Some error stack', + type: 'Some error type', + stack_trace: 'Some error stack', }, message: 'Some error message', - meta: { + version: { from: 'v7', to: 'v8', }, - pid: 5355, + process: { + pid: 5355, + }, + }); +}); + +test('format() meta can override @timestamp', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + message: 'foo', + timestamp, + level: LogLevel.Debug, + context: 'bar', + pid: 3, + meta: { + '@timestamp': '2099-05-01T09:30:22.011-05:00', + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2099-05-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'DEBUG', + logger: 'bar', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can merge override logs', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + timestamp, + message: 'foo', + level: LogLevel.Error, + context: 'bar', + pid: 3, + meta: { + log: { + kbn_custom_field: 'hello', + }, + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'ERROR', + logger: 'bar', + kbn_custom_field: 'hello', + }, + process: { + pid: 3, + }, + }); +}); + +test('format() meta can override log level objects', () => { + const layout = new JsonLayout(); + expect( + JSON.parse( + layout.format({ + timestamp, + context: '123', + message: 'foo', + level: LogLevel.Error, + pid: 3, + meta: { + log: { + level: 'FATAL', + }, + }, + }) + ) + ).toStrictEqual({ + '@timestamp': '2012-02-01T09:30:22.011-05:00', + message: 'foo', + log: { + level: 'FATAL', + logger: '123', + }, + process: { + pid: 3, + }, }); }); diff --git a/src/core/server/logging/layouts/json_layout.ts b/src/core/server/logging/layouts/json_layout.ts index ad8c33d7cb023..04416184a5957 100644 --- a/src/core/server/logging/layouts/json_layout.ts +++ b/src/core/server/logging/layouts/json_layout.ts @@ -18,6 +18,7 @@ */ import moment from 'moment-timezone'; +import { merge } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { LogRecord } from '../log_record'; @@ -46,20 +47,28 @@ export class JsonLayout implements Layout { return { message: error.message, - name: error.name, - stack: error.stack, + type: error.name, + stack_trace: error.stack, }; } public format(record: LogRecord): string { - return JSON.stringify({ - '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), - context: record.context, - error: JsonLayout.errorToSerializableObject(record.error), - level: record.level.id.toUpperCase(), - message: record.message, - meta: record.meta, - pid: record.pid, - }); + return JSON.stringify( + merge( + { + '@timestamp': moment(record.timestamp).format('YYYY-MM-DDTHH:mm:ss.SSSZ'), + message: record.message, + error: JsonLayout.errorToSerializableObject(record.error), + log: { + level: record.level.id.toUpperCase(), + logger: record.context, + }, + process: { + pid: record.pid, + }, + }, + record.meta + ) + ); } } diff --git a/src/core/server/logging/logging_system.test.ts b/src/core/server/logging/logging_system.test.ts index ac52973081106..afe58ddff92aa 100644 --- a/src/core/server/logging/logging_system.test.ts +++ b/src/core/server/logging/logging_system.test.ts @@ -23,7 +23,7 @@ jest.mock('fs', () => ({ createWriteStream: jest.fn(() => ({ write: mockStreamWrite })), })); -const dynamicProps = { pid: expect.any(Number) }; +const dynamicProps = { process: { pid: expect.any(Number) } }; jest.mock('../../../legacy/server/logging/rotate', () => ({ setupLoggingRotate: jest.fn().mockImplementation(() => Promise.resolve({})), @@ -61,8 +61,10 @@ test('uses default memory buffer logger until config is provided', () => { anotherLogger.fatal('fatal message', { some: 'value' }); expect(bufferAppendSpy).toHaveBeenCalledTimes(2); - expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot(dynamicProps); - expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot(dynamicProps); + + // pid at args level, nested under process for ECS writes + expect(bufferAppendSpy.mock.calls[0][0]).toMatchSnapshot({ pid: expect.any(Number) }); + expect(bufferAppendSpy.mock.calls[1][0]).toMatchSnapshot({ pid: expect.any(Number) }); }); test('flushes memory buffer logger and switches to real logger once config is provided', () => { @@ -210,20 +212,26 @@ test('setContextConfig() updates config with relative contexts', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(4); // Parent contexts are unaffected expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests', message: 'tests log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests', + }, }); expect(JSON.parse(mockConsoleLog.mock.calls[1][0])).toMatchObject({ - context: 'tests.child', message: 'tests.child log to default!', - level: 'ERROR', + log: { + level: 'ERROR', + logger: 'tests.child', + }, }); // Customized context is logged in both appender formats expect(JSON.parse(mockConsoleLog.mock.calls[2][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[3][0]).toMatchInlineSnapshot( `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` @@ -259,9 +267,11 @@ test('setContextConfig() updates config for a root context', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(3); // Parent context is unaffected expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests', message: 'tests log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests', + }, }); // Customized contexts expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( @@ -299,9 +309,11 @@ test('custom context configs are applied on subsequent calls to update()', () => // Customized context is logged in both appender formats still expect(mockConsoleLog).toHaveBeenCalledTimes(2); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'DEBUG', + log: { + level: 'DEBUG', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( `"[DEBUG][tests.child.grandchild] tests.child.grandchild log to default and custom!"` @@ -347,9 +359,11 @@ test('subsequent calls to setContextConfig() for the same context override the p // Only the warn log should have been logged expect(mockConsoleLog).toHaveBeenCalledTimes(2); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default and custom!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests.child.grandchild', + }, }); expect(mockConsoleLog.mock.calls[1][0]).toMatchInlineSnapshot( `"[WARN ][tests.child.grandchild] second pattern! tests.child.grandchild log to default and custom!"` @@ -384,8 +398,10 @@ test('subsequent calls to setContextConfig() for the same context can disable th // Only the warn log should have been logged once on the default appender expect(mockConsoleLog).toHaveBeenCalledTimes(1); expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ - context: 'tests.child.grandchild', message: 'tests.child.grandchild log to default!', - level: 'WARN', + log: { + level: 'WARN', + logger: 'tests.child.grandchild', + }, }); }); From f0b4986099911fcf4c7bb88c9fde98252f26aecc Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 15 Jul 2020 06:53:40 -0700 Subject: [PATCH 08/52] Restores task for downloading Chromium builds (#71749) This was removed in https://github.com/elastic/kibana/pull/69165 without realizing it was used by the packer cache. I renamed it to be more inline with what it actually does. Signed-off-by: Tyler Smalley --- .ci/packer_cache_for_branch.sh | 2 +- x-pack/gulpfile.js | 2 ++ x-pack/tasks/download_chromium.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 x-pack/tasks/download_chromium.ts diff --git a/.ci/packer_cache_for_branch.sh b/.ci/packer_cache_for_branch.sh index 5b4a94be50fa2..ab0ab845b2dc3 100755 --- a/.ci/packer_cache_for_branch.sh +++ b/.ci/packer_cache_for_branch.sh @@ -18,7 +18,7 @@ node scripts/es snapshot --download-only; node scripts/es snapshot --license=oss --download-only; # download reporting browsers -(cd "x-pack" && yarn gulp prepare); +(cd "x-pack" && yarn gulp downloadChromium); # cache the chromedriver archive chromedriverDistVersion="$(node -e "console.log(require('chromedriver').version)")" diff --git a/x-pack/gulpfile.js b/x-pack/gulpfile.js index adccaccecd7da..7e5ab9b18f019 100644 --- a/x-pack/gulpfile.js +++ b/x-pack/gulpfile.js @@ -9,11 +9,13 @@ require('../src/setup_node_env'); const { buildTask } = require('./tasks/build'); const { devTask } = require('./tasks/dev'); const { testTask, testKarmaTask, testKarmaDebugTask } = require('./tasks/test'); +const { downloadChromium } = require('./tasks/download_chromium'); // export the tasks that are runnable from the CLI module.exports = { build: buildTask, dev: devTask, + downloadChromium, test: testTask, 'test:karma': testKarmaTask, 'test:karma:debug': testKarmaDebugTask, diff --git a/x-pack/tasks/download_chromium.ts b/x-pack/tasks/download_chromium.ts new file mode 100644 index 0000000000000..1f7f8a92dfffb --- /dev/null +++ b/x-pack/tasks/download_chromium.ts @@ -0,0 +1,26 @@ +/* + * 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 { LevelLogger } from '../plugins/reporting/server/lib'; +import { ensureBrowserDownloaded } from '../plugins/reporting/server/browsers/download'; + +export const downloadChromium = async () => { + // eslint-disable-next-line no-console + const consoleLogger = (tag: string) => (message: unknown) => console.log(tag, message); + const innerLogger = { + get: () => innerLogger, + debug: consoleLogger('debug'), + info: consoleLogger('info'), + warn: consoleLogger('warn'), + trace: consoleLogger('trace'), + error: consoleLogger('error'), + fatal: consoleLogger('fatal'), + log: consoleLogger('log'), + }; + + const levelLogger = new LevelLogger(innerLogger); + await ensureBrowserDownloaded(levelLogger); +}; From 6711d0d9e0408f191492e59c7ce5079fadc17ecb Mon Sep 17 00:00:00 2001 From: Bohdan Tsymbala Date: Wed, 15 Jul 2020 15:55:55 +0200 Subject: [PATCH 09/52] Fixed the beta badge layout. (#71835) --- .../public/management/pages/endpoint_hosts/view/index.tsx | 2 +- .../public/management/pages/policy/view/policy_list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c5d47e87c3e1b..4c8d2c5a6df4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -377,7 +377,7 @@ export const HostList = () => { data-test-subj="hostPage" headerLeft={ <> - +

diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 8dbfbeeb5d8d6..20b6534f7664e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -396,7 +396,7 @@ export const PolicyList = React.memo(() => { data-test-subj="policyListPage" headerLeft={ <> - +

From 0173ef35288b7633ec457e601482ce1a44171220 Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Wed, 15 Jul 2020 09:35:37 -0500 Subject: [PATCH 10/52] add short sleep before clicking Remove on sample data (#71104) Co-authored-by: Elastic Machine --- test/functional/page_objects/home_page.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/functional/page_objects/home_page.ts b/test/functional/page_objects/home_page.ts index 6a503f4f73b66..2d78de49a4f94 100644 --- a/test/functional/page_objects/home_page.ts +++ b/test/functional/page_objects/home_page.ts @@ -54,6 +54,10 @@ export function HomePageProvider({ getService, getPageObjects }: FtrProviderCont async removeSampleDataSet(id: string) { // looks like overkill but we're hitting flaky cases where we click but it doesn't remove await testSubjects.waitForEnabled(`removeSampleDataSet${id}`); + // https://github.com/elastic/kibana/issues/65949 + // Even after waiting for the "Remove" button to be enabled we still have failures + // where it appears the click just didn't work. + await PageObjects.common.sleep(1010); await testSubjects.click(`removeSampleDataSet${id}`); await this._waitForSampleDataLoadingAction(id); } From 1ac56d7bfcd1b9df542422afbcf4b3e2caaafac3 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Wed, 15 Jul 2020 16:44:11 +0200 Subject: [PATCH 11/52] [uiActions] Support emitting nested triggers and actions (#70602) * Introduce automatically executed actions * Introduce batching of emitted triggers to be execute on the macro task --- ...plyglobalfilteractioncontext.embeddable.md | 11 ++ ....applyglobalfilteractioncontext.filters.md | 11 ++ ...a-public.applyglobalfilteractioncontext.md | 20 +++ ...globalfilteractioncontext.timefieldname.md | 11 ++ .../kibana-plugin-plugins-data-public.md | 1 + ...plugin-plugins-data-public.plugin.setup.md | 4 +- .../public/actions/actions.tsx | 16 +-- .../ui_actions_explorer/public/plugin.tsx | 8 +- .../public/actions/apply_filter_action.ts | 2 + .../create_filters_from_range_select.ts | 2 +- .../create_filters_from_value_click.ts | 2 +- src/plugins/data/public/actions/index.ts | 10 +- .../public/actions/select_range_action.ts | 61 +++------ .../data/public/actions/value_click_action.ts | 101 ++++----------- src/plugins/data/public/index.ts | 2 + src/plugins/data/public/plugin.ts | 36 ++++-- src/plugins/data/public/public.api.md | 24 +++- .../public/lib/panel/embeddable_panel.tsx | 3 +- src/plugins/ui_actions/kibana.json | 1 + .../ui_actions/public/actions/action.ts | 14 ++ .../public/actions/action_internal.ts | 5 + .../build_eui_context_menu_panels.tsx | 20 ++- .../service/ui_actions_execution_service.ts | 121 ++++++++++++++++++ .../public/service/ui_actions_service.ts | 2 + .../tests/execute_trigger_actions.test.ts | 46 ++++++- .../public/triggers/trigger_internal.ts | 39 ++---- src/plugins/ui_actions/public/types.ts | 10 +- 27 files changed, 368 insertions(+), 215 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md create mode 100644 src/plugins/ui_actions/public/service/ui_actions_execution_service.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md new file mode 100644 index 0000000000000..027ae4209b77f --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md) + +## ApplyGlobalFilterActionContext.embeddable property + +Signature: + +```typescript +embeddable?: IEmbeddable; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md new file mode 100644 index 0000000000000..6d1d20580fb19 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md) + +## ApplyGlobalFilterActionContext.filters property + +Signature: + +```typescript +filters: Filter[]; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md new file mode 100644 index 0000000000000..62817cd0a1e33 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) + +## ApplyGlobalFilterActionContext interface + +Signature: + +```typescript +export interface ApplyGlobalFilterActionContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [embeddable](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.embeddable.md) | IEmbeddable | | +| [filters](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.filters.md) | Filter[] | | +| [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md) | string | | + diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md new file mode 100644 index 0000000000000..a5cf58018ec65 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) > [timeFieldName](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.timefieldname.md) + +## ApplyGlobalFilterActionContext.timeFieldName property + +Signature: + +```typescript +timeFieldName?: string; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md index 4852ad15781c7..db41936f35cca 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.md @@ -48,6 +48,7 @@ | Interface | Description | | --- | --- | | [AggParamOption](./kibana-plugin-plugins-data-public.aggparamoption.md) | | +| [ApplyGlobalFilterActionContext](./kibana-plugin-plugins-data-public.applyglobalfilteractioncontext.md) | | | [DataPublicPluginSetup](./kibana-plugin-plugins-data-public.datapublicpluginsetup.md) | | | [DataPublicPluginStart](./kibana-plugin-plugins-data-public.datapublicpluginstart.md) | | | [EsQueryConfig](./kibana-plugin-plugins-data-public.esqueryconfig.md) | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md index 7bae595e75ad0..a0c9b38792825 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.plugin.setup.md @@ -7,14 +7,14 @@ Signature: ```typescript -setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; +setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| core | CoreSetup | | +| core | CoreSetup<DataStartDependencies, DataPublicPluginStart> | | | { expressions, uiActions, usageCollection } | DataSetupDependencies | | Returns: diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 4ef8d5bf4d9c6..6d83362e998bc 100644 --- a/examples/ui_actions_explorer/public/actions/actions.tsx +++ b/examples/ui_actions_explorer/public/actions/actions.tsx @@ -31,7 +31,7 @@ export const ACTION_VIEW_IN_MAPS = 'ACTION_VIEW_IN_MAPS'; export const ACTION_TRAVEL_GUIDE = 'ACTION_TRAVEL_GUIDE'; export const ACTION_CALL_PHONE_NUMBER = 'ACTION_CALL_PHONE_NUMBER'; export const ACTION_EDIT_USER = 'ACTION_EDIT_USER'; -export const ACTION_PHONE_USER = 'ACTION_PHONE_USER'; +export const ACTION_TRIGGER_PHONE_USER = 'ACTION_TRIGGER_PHONE_USER'; export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY'; export const showcasePluggability = createAction({ @@ -120,19 +120,13 @@ export interface UserContext { update: (user: User) => void; } -export const createPhoneUserAction = (getUiActionsApi: () => Promise) => - createAction({ - type: ACTION_PHONE_USER, +export const createTriggerPhoneTriggerAction = (getUiActionsApi: () => Promise) => + createAction({ + type: ACTION_TRIGGER_PHONE_USER, getDisplayName: () => 'Call phone number', + shouldAutoExecute: async () => true, isCompatible: async ({ user }) => user.phone !== undefined, execute: async ({ user }) => { - // One option - execute the more specific action directly. - // makePhoneCallAction.execute({ phone: user.phone }); - - // Another option - emit the trigger and automatically get *all* the actions attached - // to the phone number trigger. - // TODO: we need to figure out the best way to handle these nested actions however, since - // we don't want multiple context menu's to pop up. if (user.phone !== undefined) { (await getUiActionsApi()).executeTriggerActions(PHONE_TRIGGER, { phone: user.phone }); } diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index 670138b43b9c4..b28e5e7a9f692 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -23,7 +23,6 @@ import { PHONE_TRIGGER, USER_TRIGGER, COUNTRY_TRIGGER, - createPhoneUserAction, lookUpWeatherAction, viewInMapsAction, createEditUserAction, @@ -37,7 +36,8 @@ import { ACTION_CALL_PHONE_NUMBER, ACTION_TRAVEL_GUIDE, ACTION_VIEW_IN_MAPS, - ACTION_PHONE_USER, + ACTION_TRIGGER_PHONE_USER, + createTriggerPhoneTriggerAction, } from './actions/actions'; import { DeveloperExamplesSetup } from '../../developer_examples/public'; import image from './ui_actions.png'; @@ -64,7 +64,7 @@ declare module '../../../src/plugins/ui_actions/public' { [ACTION_CALL_PHONE_NUMBER]: PhoneContext; [ACTION_TRAVEL_GUIDE]: CountryContext; [ACTION_VIEW_IN_MAPS]: CountryContext; - [ACTION_PHONE_USER]: UserContext; + [ACTION_TRIGGER_PHONE_USER]: UserContext; } } @@ -84,7 +84,7 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) + createTriggerPhoneTriggerAction(async () => (await startServices)[1].uiActions) ); deps.uiActions.addTriggerAction( USER_TRIGGER, diff --git a/src/plugins/data/public/actions/apply_filter_action.ts b/src/plugins/data/public/actions/apply_filter_action.ts index 7e8ed5ec8fb22..a2621e6ce8802 100644 --- a/src/plugins/data/public/actions/apply_filter_action.ts +++ b/src/plugins/data/public/actions/apply_filter_action.ts @@ -22,6 +22,7 @@ import { toMountPoint } from '../../../kibana_react/public'; import { ActionByType, createAction, IncompatibleActionError } from '../../../ui_actions/public'; import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../ui/apply_filters'; +import type { IEmbeddable } from '../../../embeddable/public'; import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER'; @@ -29,6 +30,7 @@ export const ACTION_GLOBAL_APPLY_FILTER = 'ACTION_GLOBAL_APPLY_FILTER'; export interface ApplyGlobalFilterActionContext { filters: Filter[]; timeFieldName?: string; + embeddable?: IEmbeddable; } async function isCompatible(context: ApplyGlobalFilterActionContext) { diff --git a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts index a0eb49d773f3d..d9aa1b8ec8048 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts @@ -22,7 +22,7 @@ import moment from 'moment'; import { esFilters, IFieldType, RangeFilterParams } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; import { deserializeAggConfig } from '../../search/expressions/utils'; -import { RangeSelectContext } from '../../../../embeddable/public'; +import type { RangeSelectContext } from '../../../../embeddable/public'; export async function createFiltersFromRangeSelectAction(event: RangeSelectContext['data']) { const column: Record = event.table.columns[event.column]; diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts index 1974b9f776748..9429df91f693c 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts @@ -21,7 +21,7 @@ import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { deserializeAggConfig } from '../../search/expressions'; import { esFilters, Filter } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; -import { ValueClickContext } from '../../../../embeddable/public'; +import type { ValueClickContext } from '../../../../embeddable/public'; /** * For terms aggregations on `__other__` buckets, this assembles a list of applicable filter diff --git a/src/plugins/data/public/actions/index.ts b/src/plugins/data/public/actions/index.ts index ef9014aafe82d..692996cf6fd19 100644 --- a/src/plugins/data/public/actions/index.ts +++ b/src/plugins/data/public/actions/index.ts @@ -17,8 +17,12 @@ * under the License. */ -export { ACTION_GLOBAL_APPLY_FILTER, createFilterAction } from './apply_filter_action'; +export { + ACTION_GLOBAL_APPLY_FILTER, + createFilterAction, + ApplyGlobalFilterActionContext, +} from './apply_filter_action'; export { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; export { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -export { selectRangeAction } from './select_range_action'; -export { valueClickAction } from './value_click_action'; +export * from './select_range_action'; +export * from './value_click_action'; diff --git a/src/plugins/data/public/actions/select_range_action.ts b/src/plugins/data/public/actions/select_range_action.ts index 49766143b5588..1781da980dc30 100644 --- a/src/plugins/data/public/actions/select_range_action.ts +++ b/src/plugins/data/public/actions/select_range_action.ts @@ -17,60 +17,39 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; import { - createAction, - IncompatibleActionError, ActionByType, + APPLY_FILTER_TRIGGER, + createAction, + UiActionsStart, } from '../../../../plugins/ui_actions/public'; import { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -import { RangeSelectContext } from '../../../embeddable/public'; -import { FilterManager, TimefilterContract, esFilters } from '..'; - -export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; +import type { RangeSelectContext } from '../../../embeddable/public'; export type SelectRangeActionContext = RangeSelectContext; -async function isCompatible(context: SelectRangeActionContext) { - try { - return Boolean(await createFiltersFromRangeSelectAction(context.data)); - } catch { - return false; - } -} +export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; -export function selectRangeAction( - filterManager: FilterManager, - timeFilter: TimefilterContract +export function createSelectRangeAction( + getStartServices: () => { uiActions: UiActionsStart } ): ActionByType { return createAction({ type: ACTION_SELECT_RANGE, id: ACTION_SELECT_RANGE, - getIconType: () => 'filter', - getDisplayName: () => { - return i18n.translate('data.filter.applyFilterActionTitle', { - defaultMessage: 'Apply filter to current view', - }); - }, - isCompatible, - execute: async ({ data }: SelectRangeActionContext) => { - if (!(await isCompatible({ data }))) { - throw new IncompatibleActionError(); - } - - const selectedFilters = await createFiltersFromRangeSelectAction(data); - - if (data.timeFieldName) { - const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( - data.timeFieldName, - selectedFilters - ); - filterManager.addFilters(restOfFilters); - if (timeRangeFilter) { - esFilters.changeTimeFilter(timeFilter, timeRangeFilter); + shouldAutoExecute: async () => true, + execute: async (context: SelectRangeActionContext) => { + try { + const filters = await createFiltersFromRangeSelectAction(context.data); + if (filters.length > 0) { + await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({ + filters, + embeddable: context.embeddable, + timeFieldName: context.data.timeFieldName, + }); } - } else { - filterManager.addFilters(selectedFilters); + } catch (e) { + // eslint-disable-next-line no-console + console.warn(`Error [ACTION_SELECT_RANGE]: can\'t extract filters from action context`); } }, }); diff --git a/src/plugins/data/public/actions/value_click_action.ts b/src/plugins/data/public/actions/value_click_action.ts index dd74a7ee507f3..81e62380eacfb 100644 --- a/src/plugins/data/public/actions/value_click_action.ts +++ b/src/plugins/data/public/actions/value_click_action.ts @@ -17,98 +17,41 @@ * under the License. */ -import { i18n } from '@kbn/i18n'; -import { toMountPoint } from '../../../../plugins/kibana_react/public'; import { ActionByType, + APPLY_FILTER_TRIGGER, createAction, - IncompatibleActionError, + UiActionsStart, } from '../../../../plugins/ui_actions/public'; -import { getOverlays, getIndexPatterns } from '../services'; -import { applyFiltersPopover } from '../ui/apply_filters'; import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; -import { ValueClickContext } from '../../../embeddable/public'; -import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; - -export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; +import type { Filter } from '../../common/es_query/filters'; +import type { ValueClickContext } from '../../../embeddable/public'; export type ValueClickActionContext = ValueClickContext; +export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; -async function isCompatible(context: ValueClickActionContext) { - try { - const filters: Filter[] = await createFiltersFromValueClickAction(context.data); - return filters.length > 0; - } catch { - return false; - } -} - -export function valueClickAction( - filterManager: FilterManager, - timeFilter: TimefilterContract +export function createValueClickAction( + getStartServices: () => { uiActions: UiActionsStart } ): ActionByType { return createAction({ type: ACTION_VALUE_CLICK, id: ACTION_VALUE_CLICK, - getIconType: () => 'filter', - getDisplayName: () => { - return i18n.translate('data.filter.applyFilterActionTitle', { - defaultMessage: 'Apply filter to current view', - }); - }, - isCompatible, - execute: async ({ data }: ValueClickActionContext) => { - if (!(await isCompatible({ data }))) { - throw new IncompatibleActionError(); - } - - const filters: Filter[] = await createFiltersFromValueClickAction(data); - - let selectedFilters = filters; - - if (filters.length > 1) { - const indexPatterns = await Promise.all( - filters.map((filter) => { - return getIndexPatterns().get(filter.meta.index!); - }) - ); - - const filterSelectionPromise: Promise = new Promise((resolve) => { - const overlay = getOverlays().openModal( - toMountPoint( - applyFiltersPopover( - filters, - indexPatterns, - () => { - overlay.close(); - resolve([]); - }, - (filterSelection: Filter[]) => { - overlay.close(); - resolve(filterSelection); - } - ) - ), - { - 'data-test-subj': 'selectFilterOverlay', - } - ); - }); - - selectedFilters = await filterSelectionPromise; - } - - if (data.timeFieldName) { - const { timeRangeFilter, restOfFilters } = esFilters.extractTimeFilter( - data.timeFieldName, - selectedFilters - ); - filterManager.addFilters(restOfFilters); - if (timeRangeFilter) { - esFilters.changeTimeFilter(timeFilter, timeRangeFilter); + shouldAutoExecute: async () => true, + execute: async (context: ValueClickActionContext) => { + try { + const filters: Filter[] = await createFiltersFromValueClickAction(context.data); + if (filters.length > 0) { + await getStartServices().uiActions.getTrigger(APPLY_FILTER_TRIGGER).exec({ + filters, + embeddable: context.embeddable, + timeFieldName: context.data.timeFieldName, + }); } - } else { - filterManager.addFilters(selectedFilters); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Error [ACTION_EMIT_APPLY_FILTER_TRIGGER]: can\'t extract filters from action context` + ); } }, }); diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index 6328e694193c9..846471420327f 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -438,6 +438,8 @@ export { export { isTimeRange, isQuery, isFilter, isFilters } from '../common'; +export { ApplyGlobalFilterActionContext } from './actions'; + export * from '../common/field_mapping'; /* diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index 323a32ea362ac..68c0f506f121d 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -69,18 +69,15 @@ import { createFilterAction, createFiltersFromValueClickAction, createFiltersFromRangeSelectAction, -} from './actions'; -import { ApplyGlobalFilterActionContext } from './actions/apply_filter_action'; -import { - selectRangeAction, - SelectRangeActionContext, + ApplyGlobalFilterActionContext, ACTION_SELECT_RANGE, -} from './actions/select_range_action'; -import { - valueClickAction, ACTION_VALUE_CLICK, + SelectRangeActionContext, ValueClickActionContext, -} from './actions/value_click_action'; + createValueClickAction, + createSelectRangeAction, +} from './actions'; + import { SavedObjectsClientPublicToCommon } from './index_patterns'; import { indexPatternLoad } from './index_patterns/expressions/load_index_pattern'; @@ -92,7 +89,14 @@ declare module '../../ui_actions/public' { } } -export class DataPublicPlugin implements Plugin { +export class DataPublicPlugin + implements + Plugin< + DataPublicPluginSetup, + DataPublicPluginStart, + DataSetupDependencies, + DataStartDependencies + > { private readonly autocomplete: AutocompleteService; private readonly searchService: SearchService; private readonly fieldFormatsService: FieldFormatsService; @@ -110,13 +114,13 @@ export class DataPublicPlugin implements Plugin, { expressions, uiActions, usageCollection }: DataSetupDependencies ): DataPublicPluginSetup { const startServices = createStartServicesGetter(core.getStartServices); const getInternalStartServices = (): InternalStartServices => { - const { core: coreStart, self }: any = startServices(); + const { core: coreStart, self } = startServices(); return { fieldFormats: self.fieldFormats, notifications: coreStart.notifications, @@ -140,12 +144,16 @@ export class DataPublicPlugin implements Plugin ({ + uiActions: startServices().plugins.uiActions, + })) ); uiActions.addTriggerAction( VALUE_CLICK_TRIGGER, - valueClickAction(queryService.filterManager, queryService.timefilter.timefilter) + createValueClickAction(() => ({ + uiActions: startServices().plugins.uiActions, + })) ); return { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f8b8cb43b2297..38e0416233e25 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -250,6 +250,20 @@ export class AggParamType extends Ba makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig; } +// Warning: (ae-missing-release-tag) "ApplyGlobalFilterActionContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface ApplyGlobalFilterActionContext { + // Warning: (ae-forgotten-export) The symbol "IEmbeddable" needs to be exported by the entry point index.d.ts + // + // (undocumented) + embeddable?: IEmbeddable; + // (undocumented) + filters: Filter[]; + // (undocumented) + timeFieldName?: string; +} + // Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1443,18 +1457,16 @@ export type PhrasesFilter = Filter & { meta: PhrasesFilterMeta; }; +// Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "DataPublicPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export class Plugin implements Plugin_2 { +export class Plugin implements Plugin_2 { // Warning: (ae-forgotten-export) The symbol "ConfigSchema" needs to be exported by the entry point index.d.ts constructor(initializerContext: PluginInitializerContext_2); - // Warning: (ae-forgotten-export) The symbol "DataSetupDependencies" needs to be exported by the entry point index.d.ts - // // (undocumented) - setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; - // Warning: (ae-forgotten-export) The symbol "DataStartDependencies" needs to be exported by the entry point index.d.ts - // + setup(core: CoreSetup, { expressions, uiActions, usageCollection }: DataSetupDependencies): DataPublicPluginSetup; // (undocumented) start(core: CoreStart_2, { uiActions }: DataStartDependencies): DataPublicPluginStart; // (undocumented) diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 8cf2e015f88cf..cb02ffc470e95 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -311,8 +311,7 @@ export class EmbeddablePanel extends React.Component { const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sortedActions, - actionContext: { embeddable: this.props.embeddable }, + actions: sortedActions.map((action) => [action, { embeddable: this.props.embeddable }]), closeMenu: this.closeMyContextMenuPanel, }); }; diff --git a/src/plugins/ui_actions/kibana.json b/src/plugins/ui_actions/kibana.json index 7b24b3cc5c48b..337c5ddf0fd5c 100644 --- a/src/plugins/ui_actions/kibana.json +++ b/src/plugins/ui_actions/kibana.json @@ -7,6 +7,7 @@ "public/tests/test_samples" ], "requiredBundles": [ + "kibanaUtils", "kibanaReact" ] } diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index f5dbbc9f923ac..bc5f36acb8f0c 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -68,6 +68,13 @@ export interface Action * Executes the action. */ execute(context: Context): Promise; + + /** + * Determines if action should be executed automatically, + * without first showing up in context menu. + * false by default. + */ + shouldAutoExecute?(context: Context): Promise; } /** @@ -89,6 +96,13 @@ export interface ActionDefinition * Executes the action. */ execute(context: Context): Promise; + + /** + * Determines if action should be executed automatically, + * without first showing up in context menu. + * false by default. + */ + shouldAutoExecute?(context: Context): Promise; } export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts index 10eb760b13089..a22b3fa5b0367 100644 --- a/src/plugins/ui_actions/public/actions/action_internal.ts +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -65,4 +65,9 @@ export class ActionInternal if (!this.definition.getHref) return undefined; return await this.definition.getHref(context); } + + public async shouldAutoExecute(context: Context): Promise { + if (!this.definition.shouldAutoExecute) return false; + return this.definition.shouldAutoExecute(context); + } } diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 74e9ef96b575b..7b87a5992a7f5 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -23,28 +23,28 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +import { BaseContext } from '../types'; export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { defaultMessage: 'Options', }); +type ActionWithContext = [Action, Context]; + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, - actionContext, title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: ActionWithContext[]; title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, - actionContext, closeMenu, }); @@ -58,17 +58,15 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, - actionContext, closeMenu, }: { - actions: Array>; - actionContext: Context; + actions: ActionWithContext[]; closeMenu: () => void; }) { const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async (action, index) => { + const promises = actions.map(async ([action, actionContext], index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts new file mode 100644 index 0000000000000..7393989672e9d --- /dev/null +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -0,0 +1,121 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { uniqBy } from 'lodash'; +import { Action } from '../actions'; +import { BaseContext } from '../types'; +import { defer as createDefer, Defer } from '../../../kibana_utils/public'; +import { buildContextMenuForActions, openContextMenu } from '../context_menu'; +import { Trigger } from '../triggers'; + +interface ExecuteActionTask { + action: Action; + context: BaseContext; + trigger: Trigger; + defer: Defer; +} + +export class UiActionsExecutionService { + private readonly batchingQueue: ExecuteActionTask[] = []; + private readonly pendingTasks = new Set(); + + constructor() {} + + async execute({ + action, + context, + trigger, + }: { + action: Action; + context: BaseContext; + trigger: Trigger; + }): Promise { + const shouldBatch = !(await action.shouldAutoExecute?.(context)) ?? false; + const task: ExecuteActionTask = { + action, + context, + trigger, + defer: createDefer(), + }; + + if (shouldBatch) { + this.batchingQueue.push(task); + } else { + this.pendingTasks.add(task); + try { + await action.execute(context); + this.pendingTasks.delete(task); + } catch (e) { + this.pendingTasks.delete(task); + throw new Error(e); + } + } + + this.scheduleFlush(); + + return task.defer.promise; + } + + private scheduleFlush() { + /** + * Have to delay at least until next macro task + * Otherwise chain: + * Trigger -> await action.execute() -> trigger -> action + * isn't batched + * + * This basically needed to support a chain of scheduled micro tasks (async/awaits) within uiActions code + */ + setTimeout(() => { + if (this.pendingTasks.size === 0) { + const tasks = uniqBy(this.batchingQueue, (t) => t.action.id); + if (tasks.length === 1) { + this.executeSingleTask(tasks[0]); + } + if (tasks.length > 1) { + this.executeMultipleActions(tasks); + } + + this.batchingQueue.splice(0, this.batchingQueue.length); + } + }, 0); + } + + private async executeSingleTask({ context, action, defer }: ExecuteActionTask) { + try { + await action.execute(context); + defer.resolve(); + } catch (e) { + defer.reject(e); + } + } + + private async executeMultipleActions(tasks: ExecuteActionTask[]) { + const panel = await buildContextMenuForActions({ + actions: tasks.map(({ action, context }) => [action, context]), + title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain + closeMenu: () => { + tasks.forEach((t) => t.defer.resolve()); + session.close(); + }, + }); + const session = openContextMenu([panel], { + 'data-test-subj': 'multipleActionsContextMenu', + }); + } +} diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 11f5769a94648..08efffbb6b5a8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -28,6 +28,7 @@ import { ActionInternal, Action, ActionDefinition, ActionContext } from '../acti import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; +import { UiActionsExecutionService } from './ui_actions_execution_service'; export interface UiActionsServiceParams { readonly triggers?: TriggerRegistry; @@ -40,6 +41,7 @@ export interface UiActionsServiceParams { } export class UiActionsService { + public readonly executionService = new UiActionsExecutionService(); protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 983c6796eeb09..9af46f25b4fec 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -22,6 +22,7 @@ import { openContextMenu } from '../context_menu'; import { uiActionsPluginMock } from '../mocks'; import { Trigger } from '../triggers'; import { TriggerId, ActionType } from '../types'; +import { wait } from '@testing-library/dom'; jest.mock('../context_menu'); @@ -36,13 +37,15 @@ const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE' as ActionType; function createTestAction( type: string, - checkCompatibility: (context: C) => boolean + checkCompatibility: (context: C) => boolean, + autoExecutable = false ): Action { return createAction({ type: type as ActionType, id: type, isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)), execute: (context) => executeFn(context), + shouldAutoExecute: () => Promise.resolve(autoExecutable), }); } @@ -57,6 +60,7 @@ const reset = () => { executeFn.mockReset(); openContextMenuSpy.mockReset(); + jest.useFakeTimers(); }; beforeEach(reset); @@ -75,6 +79,8 @@ test('executes a single action mapped to a trigger', async () => { const start = doStart(); await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledTimes(1); expect(executeFn).toBeCalledWith(context); }); @@ -117,6 +123,8 @@ test('does not execute an incompatible action', async () => { }; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledTimes(1); }); @@ -139,8 +147,12 @@ test('shows a context menu when more than one action is mapped to a trigger', as const context = {}; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); - expect(executeFn).toBeCalledTimes(0); - expect(openContextMenu).toHaveBeenCalledTimes(1); + jest.runAllTimers(); + + await wait(() => { + expect(executeFn).toBeCalledTimes(0); + expect(openContextMenu).toHaveBeenCalledTimes(1); + }); }); test('passes whole action context to isCompatible()', async () => { @@ -161,4 +173,32 @@ test('passes whole action context to isCompatible()', async () => { const context = { foo: 'bar' }; await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); +}); + +test("doesn't show a context menu for auto executable actions", async () => { + const { setup, doStart } = uiActions; + const trigger: Trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action1 = createTestAction('test1', () => true, true); + const action2 = createTestAction('test2', () => true, false); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); + + expect(openContextMenu).toHaveBeenCalledTimes(0); + + const start = doStart(); + const context = {}; + await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + + jest.runAllTimers(); + + await wait(() => { + expect(executeFn).toBeCalledTimes(2); + expect(openContextMenu).toHaveBeenCalledTimes(0); + }); }); diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index e499c404ae745..c91468d31add5 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -20,8 +20,6 @@ import { Trigger } from './trigger'; import { TriggerContract } from './trigger_contract'; import { UiActionsService } from '../service'; -import { Action } from '../actions'; -import { buildContextMenuForActions, openContextMenu } from '../context_menu'; import { TriggerId, TriggerContextMapping } from '../types'; /** @@ -43,33 +41,14 @@ export class TriggerInternal { ); } - if (actions.length === 1) { - await this.executeSingleAction(actions[0], context); - return; - } - - await this.executeMultipleActions(actions, context); - } - - private async executeSingleAction( - action: Action, - context: TriggerContextMapping[T] - ) { - await action.execute(context); - } - - private async executeMultipleActions( - actions: Array>, - context: TriggerContextMapping[T] - ) { - const panel = await buildContextMenuForActions({ - actions, - actionContext: context, - title: this.trigger.title, - closeMenu: () => session.close(), - }); - const session = openContextMenu([panel], { - 'data-test-subj': 'multipleActionsContextMenu', - }); + await Promise.all([ + actions.map((action) => + this.service.executionService.execute({ + action, + context, + trigger: this.trigger, + }) + ), + ]); } } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 9fcd8a32881df..5631441cf9a1b 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -19,10 +19,9 @@ import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; -import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; -import { IEmbeddable } from '../../embeddable/public'; -import { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; +import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; +import type { ApplyGlobalFilterActionContext } from '../../data/public'; export type TriggerRegistry = Map>; export type ActionRegistry = Map; @@ -39,10 +38,7 @@ export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; [SELECT_RANGE_TRIGGER]: RangeSelectContext; [VALUE_CLICK_TRIGGER]: ValueClickContext; - [APPLY_FILTER_TRIGGER]: { - embeddable: IEmbeddable; - filters: Filter[]; - }; + [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; } const DEFAULT_ACTION = ''; From 99255d824d17125fbb821feee663e6b01d8a0009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Wed, 15 Jul 2020 16:58:51 +0200 Subject: [PATCH 12/52] [Form lib] Memoize form hook object and fix hook array deps (#71237) Co-authored-by: Elastic Machine Co-authored-by: Patryk Kopycinski --- .../multi_content/multi_content_context.tsx | 27 +- .../forms/multi_content/use_multi_content.ts | 31 +- .../components/form_data_provider.ts | 5 +- .../hook_form_lib/components/use_array.ts | 28 +- .../components/use_field.test.tsx | 3 +- .../forms/hook_form_lib/hooks/use_field.ts | 565 ++++++++++-------- .../hook_form_lib/hooks/use_form.test.tsx | 8 +- .../forms/hook_form_lib/hooks/use_form.ts | 433 ++++++++------ .../static/forms/hook_form_lib/types.ts | 4 +- .../steps/step_logistics.tsx | 20 +- .../configuration_form/configuration_form.tsx | 15 +- .../fields/create_field/create_field.tsx | 6 +- .../edit_field/edit_field_container.tsx | 6 +- .../templates_form/templates_form.tsx | 15 +- .../wizard_steps/step_mappings_container.tsx | 7 +- .../cases/components/add_comment/index.tsx | 17 +- .../public/cases/components/create/index.tsx | 5 +- .../cases/components/edit_connector/index.tsx | 13 +- .../cases/components/tag_list/index.tsx | 9 +- .../user_action_tree/user_action_markdown.tsx | 72 ++- .../rules/step_about_rule/index.tsx | 39 +- .../rules/step_define_rule/index.tsx | 43 +- .../rules/step_rule_actions/index.tsx | 31 +- .../rules/step_schedule_rule/index.tsx | 31 +- .../detection_engine/rules/create/index.tsx | 24 +- .../pages/detection_engine/rules/helpers.tsx | 13 - 26 files changed, 783 insertions(+), 687 deletions(-) diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx index 210b0cedccd06..c5659745f229a 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx +++ b/src/plugins/es_ui_shared/public/forms/multi_content/multi_content_context.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useEffect, useCallback, createContext, useContext } from 'react'; +import React, { useEffect, useCallback, createContext, useContext, useRef } from 'react'; import { useMultiContent, HookProps, Content, MultiContent } from './use_multi_content'; @@ -55,7 +55,14 @@ export function useMultiContentContext(contentId: K) { - const { updateContentAt, saveSnapshotAndRemoveContent, getData } = useMultiContentContext(); + const isMounted = useRef(false); + const defaultValue = useRef(undefined); + const { + updateContentAt, + saveSnapshotAndRemoveContent, + getData, + getSingleContentData, + } = useMultiContentContext(); const updateContent = useCallback( (content: Content) => { @@ -71,12 +78,22 @@ export function useContent(contentId: K) { }; }, [contentId, saveSnapshotAndRemoveContent]); - const data = getData(); - const defaultValue = data[contentId]; + useEffect(() => { + if (isMounted.current === false) { + isMounted.current = true; + } + }, []); + + if (isMounted.current === false) { + // Only read the default value once, on component mount to avoid re-rendering the + // consumer each time the multi-content validity ("isValid") changes. + defaultValue.current = getSingleContentData(contentId); + } return { - defaultValue, + defaultValue: defaultValue.current!, updateContent, getData, + getSingleContentData, }; } diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts index adc68a39a4a5b..8d470f6454b0e 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -45,6 +45,7 @@ export interface MultiContent { updateContentAt: (id: keyof T, content: Content) => void; saveSnapshotAndRemoveContent: (id: keyof T) => void; getData: () => T; + getSingleContentData: (contentId: K) => T[K]; validate: () => Promise; validation: Validation; } @@ -109,9 +110,22 @@ export function useMultiContent({ }; }, [stateData, validation]); + /** + * Read a single content data. + */ + const getSingleContentData = useCallback( + (contentId: K): T[K] => { + if (contents.current[contentId]) { + return contents.current[contentId].getData(); + } + return stateData[contentId]; + }, + [stateData] + ); + const updateContentValidity = useCallback( (updatedData: { [key in keyof T]?: boolean | undefined }): boolean | undefined => { - let allContentValidity: boolean | undefined; + let isAllContentValid: boolean | undefined = validation.isValid; setValidation((prev) => { if ( @@ -120,7 +134,7 @@ export function useMultiContent({ ) ) { // No change in validation, nothing to update - allContentValidity = prev.isValid; + isAllContentValid = prev.isValid; return prev; } @@ -129,21 +143,21 @@ export function useMultiContent({ ...updatedData, }; - allContentValidity = Object.values(nextContentsValidityState).some( + isAllContentValid = Object.values(nextContentsValidityState).some( (_isValid) => _isValid === undefined ) ? undefined : Object.values(nextContentsValidityState).every(Boolean); return { - isValid: allContentValidity, + isValid: isAllContentValid, contents: nextContentsValidityState, }; }); - return allContentValidity; + return isAllContentValid; }, - [] + [validation.isValid] ); /** @@ -163,7 +177,7 @@ export function useMultiContent({ } return Boolean(updateContentValidity(updatedValidation)); - }, [updateContentValidity]); + }, [validation.isValid, updateContentValidity]); /** * Update a content. It replaces the content in our "contents" map and update @@ -186,7 +200,7 @@ export function useMultiContent({ }); } }, - [updateContentValidity, onChange] + [updateContentValidity, onChange, getData, validate] ); /** @@ -211,6 +225,7 @@ export function useMultiContent({ return { getData, + getSingleContentData, validate, validation, updateContentAt, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts index 4c4a7f0642022..4c8e91b13b1b7 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider.ts @@ -29,6 +29,7 @@ interface Props { export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) => { const form = useFormContext(); + const { subscribe } = form; const previousRawData = useRef(form.__getFormData$().value); const [formData, setFormData] = useState(previousRawData.current); @@ -54,9 +55,9 @@ export const FormDataProvider = React.memo(({ children, pathsToWatch }: Props) = ); useEffect(() => { - const subscription = form.subscribe(onFormData); + const subscription = subscribe(onFormData); return subscription.unsubscribe; - }, [form.subscribe, onFormData]); + }, [subscribe, onFormData]); return children(formData); }); diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts index 1605c09f575f6..3688421964d2e 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_array.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useFormContext } from '../form_context'; @@ -83,14 +83,18 @@ export const UseArray = ({ const [items, setItems] = useState(initialState); - const updatePaths = (_rows: ArrayItem[]) => - _rows.map( - (row, index) => - ({ - ...row, - path: `${path}[${index}]`, - } as ArrayItem) - ); + const updatePaths = useCallback( + (_rows: ArrayItem[]) => { + return _rows.map( + (row, index) => + ({ + ...row, + path: `${path}[${index}]`, + } as ArrayItem) + ); + }, + [path] + ); const addItem = () => { setItems((previousItems) => { @@ -108,11 +112,13 @@ export const UseArray = ({ useEffect(() => { if (didMountRef.current) { - setItems(updatePaths(items)); + setItems((prev) => { + return updatePaths(prev); + }); } else { didMountRef.current = true; } - }, [path]); + }, [path, updatePaths]); return children({ items, addItem, removeItem }); }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx index 7ad32cb0bc3f0..f00beb470a9fc 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/components/use_field.test.tsx @@ -30,8 +30,9 @@ describe('', () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm(); + const { subscribe } = form; - useEffect(() => form.subscribe(onData).unsubscribe, [form]); + useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); return (
diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index b83006c6cec52..b2f00610a3d33 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types'; import { FIELD_TYPES, VALIDATION_TYPES } from '../constants'; @@ -34,21 +34,21 @@ export const useField = ( label = '', labelAppend = '', helpText = '', - validations = [], - formatters = [], - fieldsToValidateOnChange = [path], + validations, + formatters, + fieldsToValidateOnChange, errorDisplayDelay = form.__options.errorDisplayDelay, - serializer = (value: unknown) => value, - deserializer = (value: unknown) => value, + serializer, + deserializer, } = config; + const { getFormData, __removeField, __updateFormDataAt, __validateFields } = form; - const initialValue = useMemo( - () => - typeof defaultValue === 'function' - ? deserializer(defaultValue()) - : deserializer(defaultValue), - [defaultValue] - ) as T; + const initialValue = useMemo(() => { + if (typeof defaultValue === 'function') { + return deserializer ? deserializer(defaultValue()) : defaultValue(); + } + return deserializer ? deserializer(defaultValue) : defaultValue; + }, [defaultValue, deserializer]) as T; const [value, setStateValue] = useState(initialValue); const [errors, setErrors] = useState([]); @@ -64,6 +64,12 @@ export const useField = ( // -- HELPERS // ---------------------------------- + const serializeOutput: FieldHook['__serializeOutput'] = useCallback( + (rawValue = value) => { + return serializer ? serializer(rawValue) : rawValue; + }, + [serializer, value] + ); /** * Filter an array of errors with specific validation type on them @@ -84,19 +90,22 @@ export const useField = ( ); }; - const formatInputValue = (inputValue: unknown): T => { - const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; + const formatInputValue = useCallback( + (inputValue: unknown): T => { + const isEmptyString = typeof inputValue === 'string' && inputValue.trim() === ''; - if (isEmptyString) { - return inputValue as T; - } + if (isEmptyString || !formatters) { + return inputValue as T; + } - const formData = form.getFormData({ unflatten: false }); + const formData = getFormData({ unflatten: false }); - return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; - }; + return formatters.reduce((output, formatter) => formatter(output, formData), inputValue) as T; + }, + [formatters, getFormData] + ); - const onValueChange = async () => { + const onValueChange = useCallback(async () => { const changeIteration = ++changeCounter.current; const startTime = Date.now(); @@ -116,10 +125,10 @@ export const useField = ( } // Update the form data observable - form.__updateFormDataAt(path, newValue); + __updateFormDataAt(path, newValue); - // Validate field(s) and set form.isValid flag - await form.__validateFields(fieldsToValidateOnChange); + // Validate field(s) and update form.isValid state + await __validateFields(fieldsToValidateOnChange ?? [path]); if (isUnmounted.current) { return; @@ -142,9 +151,18 @@ export const useField = ( setIsChangingValue(false); } } - }; + }, [ + serializeOutput, + valueChangeListener, + errorDisplayDelay, + path, + value, + fieldsToValidateOnChange, + __updateFormDataAt, + __validateFields, + ]); - const cancelInflightValidation = () => { + const cancelInflightValidation = useCallback(() => { // Cancel any inflight validation (like an HTTP Request) if ( inflightValidation.current && @@ -153,209 +171,232 @@ export const useField = ( (inflightValidation.current as any).cancel(); inflightValidation.current = null; } - }; + }, []); - const runValidations = ({ - formData, - value: valueToValidate, - validationTypeToValidate, - }: { - formData: any; - value: unknown; - validationTypeToValidate?: string; - }): ValidationError[] | Promise => { - // By default, for fields that have an asynchronous validation - // we will clear the errors as soon as the field value changes. - clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); - - cancelInflightValidation(); - - const runAsync = async () => { - const validationErrors: ValidationError[] = []; - - for (const validation of validations) { - inflightValidation.current = null; - - const { - validator, - exitOnFail = true, - type: validationType = VALIDATION_TYPES.FIELD, - } = validation; - - if ( - typeof validationTypeToValidate !== 'undefined' && - validationType !== validationTypeToValidate - ) { - continue; - } - - inflightValidation.current = validator({ - value: (valueToValidate as unknown) as string, - errors: validationErrors, - form, - formData, - path, - }) as Promise; - - const validationResult = await inflightValidation.current; - - if (!validationResult) { - continue; - } - - validationErrors.push({ - ...validationResult, - validationType: validationType || VALIDATION_TYPES.FIELD, - }); + const clearErrors: FieldHook['clearErrors'] = useCallback( + (validationType = VALIDATION_TYPES.FIELD) => { + setErrors((previousErrors) => filterErrors(previousErrors, validationType)); + }, + [] + ); - if (exitOnFail) { - break; - } + const runValidations = useCallback( + ({ + formData, + value: valueToValidate, + validationTypeToValidate, + }: { + formData: any; + value: unknown; + validationTypeToValidate?: string; + }): ValidationError[] | Promise => { + if (!validations) { + return []; } - return validationErrors; - }; - - const runSync = () => { - const validationErrors: ValidationError[] = []; - // Sequentially execute all the validations for the field - for (const validation of validations) { - const { - validator, - exitOnFail = true, - type: validationType = VALIDATION_TYPES.FIELD, - } = validation; - - if ( - typeof validationTypeToValidate !== 'undefined' && - validationType !== validationTypeToValidate - ) { - continue; - } - - const validationResult = validator({ - value: (valueToValidate as unknown) as string, - errors: validationErrors, - form, - formData, - path, - }); - - if (!validationResult) { - continue; + // By default, for fields that have an asynchronous validation + // we will clear the errors as soon as the field value changes. + clearErrors([VALIDATION_TYPES.FIELD, VALIDATION_TYPES.ASYNC]); + + cancelInflightValidation(); + + const runAsync = async () => { + const validationErrors: ValidationError[] = []; + + for (const validation of validations) { + inflightValidation.current = null; + + const { + validator, + exitOnFail = true, + type: validationType = VALIDATION_TYPES.FIELD, + } = validation; + + if ( + typeof validationTypeToValidate !== 'undefined' && + validationType !== validationTypeToValidate + ) { + continue; + } + + inflightValidation.current = validator({ + value: (valueToValidate as unknown) as string, + errors: validationErrors, + form, + formData, + path, + }) as Promise; + + const validationResult = await inflightValidation.current; + + if (!validationResult) { + continue; + } + + validationErrors.push({ + ...validationResult, + validationType: validationType || VALIDATION_TYPES.FIELD, + }); + + if (exitOnFail) { + break; + } } - if (!!validationResult.then) { - // The validator returned a Promise: abort and run the validations asynchronously - // We keep a reference to the onflith promise so we can cancel it. - - inflightValidation.current = validationResult as Promise; - cancelInflightValidation(); - - return runAsync(); - } - - validationErrors.push({ - ...(validationResult as ValidationError), - validationType: validationType || VALIDATION_TYPES.FIELD, - }); + return validationErrors; + }; - if (exitOnFail) { - break; + const runSync = () => { + const validationErrors: ValidationError[] = []; + // Sequentially execute all the validations for the field + for (const validation of validations) { + const { + validator, + exitOnFail = true, + type: validationType = VALIDATION_TYPES.FIELD, + } = validation; + + if ( + typeof validationTypeToValidate !== 'undefined' && + validationType !== validationTypeToValidate + ) { + continue; + } + + const validationResult = validator({ + value: (valueToValidate as unknown) as string, + errors: validationErrors, + form, + formData, + path, + }); + + if (!validationResult) { + continue; + } + + if (!!validationResult.then) { + // The validator returned a Promise: abort and run the validations asynchronously + // We keep a reference to the onflith promise so we can cancel it. + + inflightValidation.current = validationResult as Promise; + cancelInflightValidation(); + + return runAsync(); + } + + validationErrors.push({ + ...(validationResult as ValidationError), + validationType: validationType || VALIDATION_TYPES.FIELD, + }); + + if (exitOnFail) { + break; + } } - } - return validationErrors; - }; + return validationErrors; + }; - // We first try to run the validations synchronously - return runSync(); - }; + // We first try to run the validations synchronously + return runSync(); + }, + [clearErrors, cancelInflightValidation, validations, form, path] + ); // -- API // ---------------------------------- - const clearErrors: FieldHook['clearErrors'] = (validationType = VALIDATION_TYPES.FIELD) => { - setErrors((previousErrors) => filterErrors(previousErrors, validationType)); - }; /** * Validate a form field, running all its validations. * If a validationType is provided then only that validation will be executed, * skipping the other type of validation that might exist. */ - const validate: FieldHook['validate'] = (validationData = {}) => { - const { - formData = form.getFormData({ unflatten: false }), - value: valueToValidate = value, - validationType, - } = validationData; - - setIsValidated(true); - setValidating(true); - - // By the time our validate function has reached completion, it’s possible - // that validate() will have been called again. If this is the case, we need - // to ignore the results of this invocation and only use the results of - // the most recent invocation to update the error state for a field - const validateIteration = ++validateCounter.current; - - const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => { - if (validateIteration === validateCounter.current) { - // This is the most recent invocation - setValidating(false); - // Update the errors array - const filteredErrors = filterErrors(errors, validationType); - setErrors([...filteredErrors, ..._validationErrors]); - } + const validate: FieldHook['validate'] = useCallback( + (validationData = {}) => { + const { + formData = getFormData({ unflatten: false }), + value: valueToValidate = value, + validationType, + } = validationData; + + setIsValidated(true); + setValidating(true); + + // By the time our validate function has reached completion, it’s possible + // that validate() will have been called again. If this is the case, we need + // to ignore the results of this invocation and only use the results of + // the most recent invocation to update the error state for a field + const validateIteration = ++validateCounter.current; + + const onValidationErrors = (_validationErrors: ValidationError[]): FieldValidateResponse => { + if (validateIteration === validateCounter.current) { + // This is the most recent invocation + setValidating(false); + // Update the errors array + setErrors((prev) => { + const filteredErrors = filterErrors(prev, validationType); + return [...filteredErrors, ..._validationErrors]; + }); + } - return { - isValid: _validationErrors.length === 0, - errors: _validationErrors, + return { + isValid: _validationErrors.length === 0, + errors: _validationErrors, + }; }; - }; - const validationErrors = runValidations({ - formData, - value: valueToValidate, - validationTypeToValidate: validationType, - }); + const validationErrors = runValidations({ + formData, + value: valueToValidate, + validationTypeToValidate: validationType, + }); - if (Reflect.has(validationErrors, 'then')) { - return (validationErrors as Promise).then(onValidationErrors); - } - return onValidationErrors(validationErrors as ValidationError[]); - }; + if (Reflect.has(validationErrors, 'then')) { + return (validationErrors as Promise).then(onValidationErrors); + } + return onValidationErrors(validationErrors as ValidationError[]); + }, + [getFormData, value, runValidations] + ); /** * Handler to change the field value * * @param newValue The new value to assign to the field */ - const setValue: FieldHook['setValue'] = (newValue) => { - if (isPristine) { - setPristine(false); - } + const setValue: FieldHook['setValue'] = useCallback( + (newValue) => { + if (isPristine) { + setPristine(false); + } - const formattedValue = formatInputValue(newValue); - setStateValue(formattedValue); - }; + const formattedValue = formatInputValue(newValue); + setStateValue(formattedValue); + return formattedValue; + }, + [formatInputValue, isPristine] + ); - const _setErrors: FieldHook['setErrors'] = (_errors) => { + const _setErrors: FieldHook['setErrors'] = useCallback((_errors) => { setErrors(_errors.map((error) => ({ validationType: VALIDATION_TYPES.FIELD, ...error }))); - }; + }, []); /** * Form "onChange" event handler * * @param event Form input change event */ - const onChange: FieldHook['onChange'] = (event) => { - const newValue = {}.hasOwnProperty.call(event!.target, 'checked') - ? event.target.checked - : event.target.value; + const onChange: FieldHook['onChange'] = useCallback( + (event) => { + const newValue = {}.hasOwnProperty.call(event!.target, 'checked') + ? event.target.checked + : event.target.value; - setValue((newValue as unknown) as T); - }; + setValue((newValue as unknown) as T); + }, + [setValue] + ); /** * As we can have multiple validation types (FIELD, ASYNC, ARRAY_ITEM), this @@ -367,48 +408,50 @@ export const useField = ( * * @param validationType The validation type to return error messages from */ - const getErrorsMessages: FieldHook['getErrorsMessages'] = (args = {}) => { - const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; - const errorMessages = errors.reduce((messages, error) => { - const isSameErrorCode = errorCode && error.code === errorCode; - const isSamevalidationType = - error.validationType === validationType || - (validationType === VALIDATION_TYPES.FIELD && - !{}.hasOwnProperty.call(error, 'validationType')); - - if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) { - return messages ? `${messages}, ${error.message}` : (error.message as string); + const getErrorsMessages: FieldHook['getErrorsMessages'] = useCallback( + (args = {}) => { + const { errorCode, validationType = VALIDATION_TYPES.FIELD } = args; + const errorMessages = errors.reduce((messages, error) => { + const isSameErrorCode = errorCode && error.code === errorCode; + const isSamevalidationType = + error.validationType === validationType || + (validationType === VALIDATION_TYPES.FIELD && + !{}.hasOwnProperty.call(error, 'validationType')); + + if (isSameErrorCode || (typeof errorCode === 'undefined' && isSamevalidationType)) { + return messages ? `${messages}, ${error.message}` : (error.message as string); + } + return messages; + }, ''); + + return errorMessages ? errorMessages : null; + }, + [errors] + ); + + const reset: FieldHook['reset'] = useCallback( + (resetOptions = { resetValue: true }) => { + const { resetValue = true } = resetOptions; + + setPristine(true); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setErrors([]); + + if (resetValue) { + setValue(initialValue); + /** + * Having to call serializeOutput() is a current bug of the lib and will be fixed + * in a future PR. The serializer function should only be called when outputting + * the form data. If we need to continuously format the data while it changes, + * we need to use the field `formatter` config. + */ + return serializeOutput(initialValue); } - return messages; - }, ''); - - return errorMessages ? errorMessages : null; - }; - - const reset: FieldHook['reset'] = (resetOptions = { resetValue: true }) => { - const { resetValue = true } = resetOptions; - - setPristine(true); - setValidating(false); - setIsChangingValue(false); - setIsValidated(false); - setErrors([]); - - if (resetValue) { - setValue(initialValue); - /** - * Having to call serializeOutput() is a current bug of the lib and will be fixed - * in a future PR. The serializer function should only be called when outputting - * the form data. If we need to continuously format the data while it changes, - * we need to use the field `formatter` config. - */ - return serializeOutput(initialValue); - } - return value; - }; - - const serializeOutput: FieldHook['__serializeOutput'] = (rawValue = value) => - serializer(rawValue); + }, + [setValue, serializeOutput, initialValue] + ); // -- EFFECTS // ---------------------------------- @@ -425,54 +468,64 @@ export const useField = ( clearTimeout(debounceTimeout.current); } }; - }, [value]); - - const field: FieldHook = { + }, [isPristine, onValueChange]); + + const field: FieldHook = useMemo(() => { + return { + path, + type, + label, + labelAppend, + helpText, + value, + errors, + form, + isPristine, + isValid: errors.length === 0, + isValidating, + isValidated, + isChangingValue, + onChange, + getErrorsMessages, + setValue, + setErrors: _setErrors, + clearErrors, + validate, + reset, + __serializeOutput: serializeOutput, + }; + }, [ path, type, label, labelAppend, helpText, value, - errors, form, isPristine, - isValid: errors.length === 0, + errors, isValidating, isValidated, isChangingValue, onChange, getErrorsMessages, setValue, - setErrors: _setErrors, + _setErrors, clearErrors, validate, reset, - __serializeOutput: serializeOutput, - }; + serializeOutput, + ]); - form.__addField(field as FieldHook); // Executed first (1) + form.__addField(field as FieldHook); useEffect(() => { - /** - * NOTE: effect cleanup actually happens *after* the new component has been mounted, - * but before the next effect callback is run. - * Ref: https://kentcdodds.com/blog/understanding-reacts-key-prop - * - * This means that, the "form.__addField(field)" outside the effect will be called *before* - * the cleanup `form.__removeField(path);` creating a race condition. - * - * TODO: See how we could refactor "use_field" & "use_form" to avoid having the - * `form.__addField(field)` call outside the effect. - */ - form.__addField(field as FieldHook); // Executed third (3) - return () => { // Remove field from the form when it is unmounted or if its path changes. isUnmounted.current = true; - form.__removeField(path); // Executed second (2) + __removeField(path); }; - }, [path]); + }, [path, __removeField]); return field; }; diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx index f332d2e6ea604..216c7974a9679 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.test.tsx @@ -135,12 +135,13 @@ describe('use_form() hook', () => { test('should allow subscribing to the form data changes and provide a handler to build the form data', async () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm(); + const { subscribe } = form; useEffect(() => { // Any time the form value changes, forward the data to the consumer - const subscription = form.subscribe(onData); + const subscription = subscribe(onData); return subscription.unsubscribe; - }, [form]); + }, [subscribe, onData]); return ( @@ -200,8 +201,9 @@ describe('use_form() hook', () => { const TestComp = ({ onData }: { onData: OnUpdateHandler }) => { const { form } = useForm({ defaultValue }); + const { subscribe } = form; - useEffect(() => form.subscribe(onData).unsubscribe, [form]); + useEffect(() => subscribe(onData).unsubscribe, [subscribe, onData]); return ( diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts index f9286d99cbf80..46b8958491e56 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form.ts @@ -17,7 +17,7 @@ * under the License. */ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { get } from 'lodash'; import { FormHook, FieldHook, FormData, FieldConfig, FieldsMap, FormConfig } from '../types'; @@ -34,28 +34,34 @@ interface UseFormReturn { } export function useForm( - formConfig: FormConfig | undefined = {} + formConfig?: FormConfig ): UseFormReturn { - const { - onSubmit, - schema, - serializer = (data: T): T => data, - deserializer = (data: T): T => data, - options = {}, - id = 'default', - } = formConfig; - - const formDefaultValue = - formConfig.defaultValue === undefined || Object.keys(formConfig.defaultValue).length === 0 - ? {} - : Object.entries(formConfig.defaultValue as object) - .filter(({ 1: value }) => value !== undefined) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); - - const formOptions = { ...DEFAULT_OPTIONS, ...options }; - const defaultValueDeserialized = useMemo(() => deserializer(formDefaultValue), [ - formConfig.defaultValue, - ]); + const { onSubmit, schema, serializer, deserializer, options, id = 'default', defaultValue } = + formConfig ?? {}; + + const formDefaultValue = useMemo(() => { + if (defaultValue === undefined || Object.keys(defaultValue).length === 0) { + return {}; + } + + return Object.entries(defaultValue as object) + .filter(({ 1: value }) => value !== undefined) + .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + }, [defaultValue]); + + const { errorDisplayDelay, stripEmptyFields: doStripEmptyFields } = options ?? {}; + const formOptions = useMemo( + () => ({ + stripEmptyFields: doStripEmptyFields ?? DEFAULT_OPTIONS.stripEmptyFields, + errorDisplayDelay: errorDisplayDelay ?? DEFAULT_OPTIONS.errorDisplayDelay, + }), + [errorDisplayDelay, doStripEmptyFields] + ); + + const defaultValueDeserialized = useMemo( + () => (deserializer ? deserializer(formDefaultValue) : formDefaultValue), + [formDefaultValue, deserializer] + ); const [isSubmitted, setIsSubmitted] = useState(false); const [isSubmitting, setSubmitting] = useState(false); @@ -81,55 +87,68 @@ export function useForm( // -- HELPERS // ---------------------------------- - const getFormData$ = (): Subject => { + const getFormData$ = useCallback((): Subject => { if (formData$.current === null) { formData$.current = new Subject({} as T); } return formData$.current; - }; - const fieldsToArray = () => Object.values(fieldsRefs.current); + }, []); - const stripEmptyFields = (fields: FieldsMap): FieldsMap => { - if (formOptions.stripEmptyFields) { - return Object.entries(fields).reduce((acc, [key, field]) => { - if (typeof field.value !== 'string' || field.value.trim() !== '') { - acc[key] = field; - } - return acc; - }, {} as FieldsMap); - } - return fields; - }; + const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []); + + const stripEmptyFields = useCallback( + (fields: FieldsMap): FieldsMap => { + if (formOptions.stripEmptyFields) { + return Object.entries(fields).reduce((acc, [key, field]) => { + if (typeof field.value !== 'string' || field.value.trim() !== '') { + acc[key] = field; + } + return acc; + }, {} as FieldsMap); + } + return fields; + }, + [formOptions] + ); + + const updateFormDataAt: FormHook['__updateFormDataAt'] = useCallback( + (path, value) => { + const _formData$ = getFormData$(); + const currentFormData = _formData$.value; + + if (currentFormData[path] !== value) { + _formData$.next({ ...currentFormData, [path]: value }); + } - const updateFormDataAt: FormHook['__updateFormDataAt'] = (path, value) => { - const _formData$ = getFormData$(); - const currentFormData = _formData$.value; - const nextValue = { ...currentFormData, [path]: value }; - _formData$.next(nextValue); - return _formData$.value; - }; + return _formData$.value; + }, + [getFormData$] + ); // -- API // ---------------------------------- - const getFormData: FormHook['getFormData'] = ( - getDataOptions: Parameters['getFormData']>[0] = { unflatten: true } - ) => { - if (getDataOptions.unflatten) { - const nonEmptyFields = stripEmptyFields(fieldsRefs.current); - const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput()); - return serializer(unflattenObject(fieldsValue)) as T; - } - - return Object.entries(fieldsRefs.current).reduce( - (acc, [key, field]) => ({ - ...acc, - [key]: field.__serializeOutput(), - }), - {} as T - ); - }; + const getFormData: FormHook['getFormData'] = useCallback( + (getDataOptions: Parameters['getFormData']>[0] = { unflatten: true }) => { + if (getDataOptions.unflatten) { + const nonEmptyFields = stripEmptyFields(fieldsRefs.current); + const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeOutput()); + return serializer + ? (serializer(unflattenObject(fieldsValue)) as T) + : (unflattenObject(fieldsValue) as T); + } - const getErrors: FormHook['getErrors'] = () => { + return Object.entries(fieldsRefs.current).reduce( + (acc, [key, field]) => ({ + ...acc, + [key]: field.__serializeOutput(), + }), + {} as T + ); + }, + [stripEmptyFields, serializer] + ); + + const getErrors: FormHook['getErrors'] = useCallback(() => { if (isValid === true) { return []; } @@ -141,11 +160,15 @@ export function useForm( } return [...acc, fieldError]; }, [] as string[]); - }; + }, [isValid, fieldsToArray]); const isFieldValid = (field: FieldHook) => field.isValid && !field.isValidating; - const updateFormValidity = () => { + const updateFormValidity = useCallback(() => { + if (isUnmounted.current) { + return; + } + const fieldsArray = fieldsToArray(); const areAllFieldsValidated = fieldsArray.every((field) => field.isValidated); @@ -158,176 +181,220 @@ export function useForm( setIsValid(isFormValid); return isFormValid; - }; + }, [fieldsToArray]); - const validateFields: FormHook['__validateFields'] = async (fieldNames) => { - const fieldsToValidate = fieldNames - .map((name) => fieldsRefs.current[name]) - .filter((field) => field !== undefined); + const validateFields: FormHook['__validateFields'] = useCallback( + async (fieldNames) => { + const fieldsToValidate = fieldNames + .map((name) => fieldsRefs.current[name]) + .filter((field) => field !== undefined); - if (fieldsToValidate.length === 0) { - // Nothing to validate - return { areFieldsValid: true, isFormValid: true }; - } + if (fieldsToValidate.length === 0) { + // Nothing to validate + return { areFieldsValid: true, isFormValid: true }; + } - const formData = getFormData({ unflatten: false }); - await Promise.all(fieldsToValidate.map((field) => field.validate({ formData }))); + const formData = getFormData({ unflatten: false }); + await Promise.all(fieldsToValidate.map((field) => field.validate({ formData }))); - const isFormValid = updateFormValidity(); - const areFieldsValid = fieldsToValidate.every(isFieldValid); + const isFormValid = updateFormValidity(); + const areFieldsValid = fieldsToValidate.every(isFieldValid); - return { areFieldsValid, isFormValid }; - }; + return { areFieldsValid, isFormValid }; + }, + [getFormData, updateFormValidity] + ); - const validateAllFields = async (): Promise => { + const validateAllFields = useCallback(async (): Promise => { const fieldsArray = fieldsToArray(); const fieldsToValidate = fieldsArray.filter((field) => !field.isValidated); - let isFormValid: boolean | undefined = isValid; + let isFormValid: boolean | undefined; if (fieldsToValidate.length === 0) { - if (isFormValid === undefined) { - // We should never enter this condition as the form validity is updated each time - // a field is validated. But sometimes, during tests it does not happen and we need - // to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated. - // In order to avoid this unintentional behaviour, we add this if condition here. - isFormValid = fieldsArray.every(isFieldValid); - setIsValid(isFormValid); - } + // We should never enter this condition as the form validity is updated each time + // a field is validated. But sometimes, during tests or race conditions it does not happen and we need + // to wait the next tick (hooks lifecycle being tricky) to make sure the "isValid" state is updated. + // In order to avoid this unintentional behaviour, we add this if condition here. + + // TODO: Fix this when adding tests to the form lib. + isFormValid = fieldsArray.every(isFieldValid); + setIsValid(isFormValid); return isFormValid; } ({ isFormValid } = await validateFields(fieldsToValidate.map((field) => field.path))); return isFormValid!; - }; + }, [fieldsToArray, validateFields]); - const addField: FormHook['__addField'] = (field) => { - fieldsRefs.current[field.path] = field; + const addField: FormHook['__addField'] = useCallback( + (field) => { + fieldsRefs.current[field.path] = field; - if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { - const fieldValue = field.__serializeOutput(); - updateFormDataAt(field.path, fieldValue); - } - }; + if (!{}.hasOwnProperty.call(getFormData$().value, field.path)) { + const fieldValue = field.__serializeOutput(); + updateFormDataAt(field.path, fieldValue); + } + }, + [getFormData$, updateFormDataAt] + ); - const removeField: FormHook['__removeField'] = (_fieldNames) => { - const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames]; - const currentFormData = { ...getFormData$().value } as FormData; + const removeField: FormHook['__removeField'] = useCallback( + (_fieldNames) => { + const fieldNames = Array.isArray(_fieldNames) ? _fieldNames : [_fieldNames]; + const currentFormData = { ...getFormData$().value } as FormData; - fieldNames.forEach((name) => { - delete fieldsRefs.current[name]; - delete currentFormData[name]; - }); + fieldNames.forEach((name) => { + delete fieldsRefs.current[name]; + delete currentFormData[name]; + }); - getFormData$().next(currentFormData as T); + getFormData$().next(currentFormData as T); - /** - * After removing a field, the form validity might have changed - * (an invalid field might have been removed and now the form is valid) - */ - updateFormValidity(); - }; + /** + * After removing a field, the form validity might have changed + * (an invalid field might have been removed and now the form is valid) + */ + updateFormValidity(); + }, + [getFormData$, updateFormValidity] + ); - const setFieldValue: FormHook['setFieldValue'] = (fieldName, value) => { + const setFieldValue: FormHook['setFieldValue'] = useCallback((fieldName, value) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setValue(value); - }; + }, []); - const setFieldErrors: FormHook['setFieldErrors'] = (fieldName, errors) => { + const setFieldErrors: FormHook['setFieldErrors'] = useCallback((fieldName, errors) => { if (fieldsRefs.current[fieldName] === undefined) { return; } fieldsRefs.current[fieldName].setErrors(errors); - }; + }, []); - const getFields: FormHook['getFields'] = () => fieldsRefs.current; + const getFields: FormHook['getFields'] = useCallback(() => fieldsRefs.current, []); - const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = (fieldName) => - get(defaultValueDeserialized, fieldName); + const getFieldDefaultValue: FormHook['getFieldDefaultValue'] = useCallback( + (fieldName) => get(defaultValueDeserialized, fieldName), + [defaultValueDeserialized] + ); - const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = (fieldName) => { - const config = (get(schema ? schema : {}, fieldName) as FieldConfig) || {}; + const readFieldConfigFromSchema: FormHook['__readFieldConfigFromSchema'] = useCallback( + (fieldName) => { + const config = (get(schema ?? {}, fieldName) as FieldConfig) || {}; - return config; - }; + return config; + }, + [schema] + ); - const submitForm: FormHook['submit'] = async (e) => { - if (e) { - e.preventDefault(); - } + const submitForm: FormHook['submit'] = useCallback( + async (e) => { + if (e) { + e.preventDefault(); + } - if (!isSubmitted) { setIsSubmitted(true); // User has attempted to submit the form at least once - } - setSubmitting(true); + setSubmitting(true); - const isFormValid = await validateAllFields(); - const formData = getFormData(); - - if (onSubmit) { - await onSubmit(formData, isFormValid!); - } - - if (isUnmounted.current === false) { - setSubmitting(false); - } + const isFormValid = await validateAllFields(); + const formData = getFormData(); - return { data: formData, isValid: isFormValid! }; - }; + if (onSubmit) { + await onSubmit(formData, isFormValid!); + } - const subscribe: FormHook['subscribe'] = (handler) => { - const subscription = getFormData$().subscribe((raw) => { - if (!isUnmounted.current) { - handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); + if (isUnmounted.current === false) { + setSubmitting(false); } - }); - formUpdateSubscribers.current.push(subscription); + return { data: formData, isValid: isFormValid! }; + }, + [validateAllFields, getFormData, onSubmit] + ); - return { - unsubscribe() { - formUpdateSubscribers.current = formUpdateSubscribers.current.filter( - (sub) => sub !== subscription - ); - return subscription.unsubscribe(); - }, - }; - }; + const subscribe: FormHook['subscribe'] = useCallback( + (handler) => { + const subscription = getFormData$().subscribe((raw) => { + if (!isUnmounted.current) { + handler({ isValid, data: { raw, format: getFormData }, validate: validateAllFields }); + } + }); + + formUpdateSubscribers.current.push(subscription); + + return { + unsubscribe() { + formUpdateSubscribers.current = formUpdateSubscribers.current.filter( + (sub) => sub !== subscription + ); + return subscription.unsubscribe(); + }, + }; + }, + [getFormData$, isValid, getFormData, validateAllFields] + ); /** * Reset all the fields of the form to their default values * and reset all the states to their original value. */ - const reset: FormHook['reset'] = (resetOptions = { resetValues: true }) => { - const { resetValues = true } = resetOptions; - const currentFormData = { ...getFormData$().value } as FormData; - Object.entries(fieldsRefs.current).forEach(([path, field]) => { - // By resetting the form, some field might be unmounted. In order - // to avoid a race condition, we check that the field still exists. - const isFieldMounted = fieldsRefs.current[path] !== undefined; - if (isFieldMounted) { - const fieldValue = field.reset({ resetValue: resetValues }); - currentFormData[path] = fieldValue; + const reset: FormHook['reset'] = useCallback( + (resetOptions = { resetValues: true }) => { + const { resetValues = true } = resetOptions; + const currentFormData = { ...getFormData$().value } as FormData; + Object.entries(fieldsRefs.current).forEach(([path, field]) => { + // By resetting the form, some field might be unmounted. In order + // to avoid a race condition, we check that the field still exists. + const isFieldMounted = fieldsRefs.current[path] !== undefined; + if (isFieldMounted) { + const fieldValue = field.reset({ resetValue: resetValues }) ?? currentFormData[path]; + currentFormData[path] = fieldValue; + } + }); + if (resetValues) { + getFormData$().next(currentFormData as T); } - }); - if (resetValues) { - getFormData$().next(currentFormData as T); - } - setIsSubmitted(false); - setSubmitting(false); - setIsValid(undefined); - }; + setIsSubmitted(false); + setSubmitting(false); + setIsValid(undefined); + }, + [getFormData$] + ); - const form: FormHook = { + const form = useMemo>(() => { + return { + isSubmitted, + isSubmitting, + isValid, + id, + submit: submitForm, + subscribe, + setFieldValue, + setFieldErrors, + getFields, + getFormData, + getErrors, + getFieldDefaultValue, + reset, + __options: formOptions, + __getFormData$: getFormData$, + __updateFormDataAt: updateFormDataAt, + __readFieldConfigFromSchema: readFieldConfigFromSchema, + __addField: addField, + __removeField: removeField, + __validateFields: validateFields, + }; + }, [ isSubmitted, isSubmitting, isValid, id, - submit: submitForm, + submitForm, subscribe, setFieldValue, setFieldErrors, @@ -336,14 +403,14 @@ export function useForm( getErrors, getFieldDefaultValue, reset, - __options: formOptions, - __getFormData$: getFormData$, - __updateFormDataAt: updateFormDataAt, - __readFieldConfigFromSchema: readFieldConfigFromSchema, - __addField: addField, - __removeField: removeField, - __validateFields: validateFields, - }; + formOptions, + getFormData$, + updateFormDataAt, + readFieldConfigFromSchema, + addField, + removeField, + validateFields, + ]); return { form, diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts index f11b61edaddf4..7e38a33f0c684 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts @@ -107,7 +107,7 @@ export interface FieldHook { errorCode?: string; }) => string | null; onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void; - setValue: (value: T) => void; + setValue: (value: T) => T; setErrors: (errors: ValidationError[]) => void; clearErrors: (type?: string | string[]) => void; validate: (validateData?: { @@ -115,7 +115,7 @@ export interface FieldHook { value?: unknown; validationType?: string; }) => FieldValidateResponse | Promise; - reset: (options?: { resetValue: boolean }) => unknown; + reset: (options?: { resetValue: boolean }) => unknown | undefined; __serializeOutput: (rawValue?: unknown) => unknown; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx index c48a23226a371..032eb93f7f9f9 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx @@ -3,7 +3,7 @@ * 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, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -44,26 +44,28 @@ export const StepLogistics: React.FunctionComponent = React.memo( options: { stripEmptyFields: false }, }); + const { isValid: isFormValid, submit, getFormData, subscribe } = form; + const { documentation } = useComponentTemplatesContext(); const [isMetaVisible, setIsMetaVisible] = useState( Boolean(defaultValue._meta && Object.keys(defaultValue._meta).length) ); - const validate = async () => { - return (await form.submit()).isValid; - }; + const validate = useCallback(async () => { + return (await submit()).isValid; + }, [submit]); useEffect(() => { onChange({ - isValid: form.isValid, + isValid: isFormValid, validate, - getData: form.getFormData, + getData: getFormData, }); - }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isFormValid, getFormData, validate, onChange]); useEffect(() => { - const subscription = form.subscribe(({ data, isValid }) => { + const subscription = subscribe(({ data, isValid }) => { onChange({ isValid, validate, @@ -71,7 +73,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( }); }); return subscription.unsubscribe; - }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, validate, onChange]); return ( diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 098e530bddb3c..86bcc796a88eb 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -94,22 +94,23 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { id: 'configurationForm', }); const dispatch = useDispatch(); + const { subscribe, submit, reset, getFormData } = form; useEffect(() => { - const subscription = form.subscribe(({ data, isValid, validate }) => { + const subscription = subscribe(({ data, isValid, validate }) => { dispatch({ type: 'configuration.update', value: { data, isValid, validate, - submitForm: form.submit, + submitForm: submit, }, }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dispatch, subscribe, submit]); useEffect(() => { if (isMounted.current === undefined) { @@ -125,18 +126,18 @@ export const ConfigurationForm = React.memo(({ value }: Props) => { // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. - form.reset({ resetValues: true }); - }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + reset({ resetValues: true }); + }, [value, reset]); useEffect(() => { return () => { isMounted.current = false; // Save a snapshot of the form state so we can get back to it when navigating back to the tab - const configurationData = form.getFormData(); + const configurationData = getFormData(); dispatch({ type: 'configuration.save', value: configurationData }); }; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [getFormData, dispatch]); return ( { - const subscription = form.subscribe((updatedFieldForm) => { + const subscription = subscribe((updatedFieldForm) => { dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dispatch, subscribe]); const cancel = () => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index d543e49d23be9..5105a2a157a6d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -26,13 +26,15 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { options: { stripEmptyFields: false }, }); + const { subscribe } = form; + useEffect(() => { - const subscription = form.subscribe((updatedFieldForm) => { + const subscription = subscribe((updatedFieldForm) => { dispatch({ type: 'fieldForm.update', value: updatedFieldForm }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, dispatch]); const exitEdit = useCallback(() => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index 79685d46b6bdd..a95579a8a141e 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -61,17 +61,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => { deserializer: formDeserializer, defaultValue: value, }); + const { subscribe, getFormData, submit: submitForm, reset } = form; const dispatch = useDispatch(); useEffect(() => { - const subscription = form.subscribe(({ data, isValid, validate }) => { + const subscription = subscribe(({ data, isValid, validate }) => { dispatch({ type: 'templates.update', - value: { data, isValid, validate, submitForm: form.submit }, + value: { data, isValid, validate, submitForm }, }); }); return subscription.unsubscribe; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [subscribe, dispatch, submitForm]); useEffect(() => { if (isMounted.current === undefined) { @@ -87,18 +88,18 @@ export const TemplatesForm = React.memo(({ value }: Props) => { // If the value has changed (it probably means that we have loaded a new JSON) // we need to reset the form to update the fields values. - form.reset({ resetValues: true }); - }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + reset({ resetValues: true }); + }, [value, reset]); useEffect(() => { return () => { isMounted.current = false; // On unmount => save in the state a snapshot of the current form data. - const dynamicTemplatesData = form.getFormData(); + const dynamicTemplatesData = getFormData(); dispatch({ type: 'templates.save', value: dynamicTemplatesData }); }; - }, [dispatch]); // eslint-disable-line react-hooks/exhaustive-deps + }, [getFormData, dispatch]); return (
diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx index 38c4a85bbe0ff..b0675c1412259 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_mappings_container.tsx @@ -14,15 +14,16 @@ interface Props { } export const StepMappingsContainer: React.FunctionComponent = ({ esDocsBase }) => { - const { defaultValue, updateContent, getData } = Forms.useContent( + const { defaultValue, updateContent, getSingleContentData } = Forms.useContent< + CommonWizardSteps, 'mappings' - ); + >('mappings'); return ( ); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 980083e8e9d20..a54cf142c18b7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -47,7 +47,7 @@ export const AddComment = React.memo( options: { stripEmptyFields: false }, schema, }); - + const { getFormData, setFieldValue, reset, submit } = form; const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'comment' @@ -55,26 +55,23 @@ export const AddComment = React.memo( useEffect(() => { if (insertQuote !== null) { - const { comment } = form.getFormData(); - form.setFieldValue( - 'comment', - `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}` - ); + const { comment } = getFormData(); + setFieldValue('comment', `${comment}${comment.length > 0 ? '\n\n' : ''}${insertQuote}`); } - }, [form, insertQuote]); + }, [getFormData, insertQuote, setFieldValue]); const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { if (onCommentSaving != null) { onCommentSaving(); } postComment(data, onCommentPosted); - form.reset(); + reset(); } - }, [form, onCommentPosted, onCommentSaving, postComment]); + }, [onCommentPosted, onCommentSaving, postComment, reset, submit]); return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 1a2697bb132b0..31e6da4269ead 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -69,6 +69,7 @@ export const Create = React.memo(() => { options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const { tags: tagOptions } = useGetTags(); const [options, setOptions] = useState( tagOptions.map((label) => ({ @@ -91,12 +92,12 @@ export const Create = React.memo(() => { const handleTimelineClick = useTimelineClick(); const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { // `postCase`'s type is incorrect, it actually returns a promise await postCase(data); } - }, [form, postCase]); + }, [submit, postCase]); const handleSetIsCancel = useCallback(() => { history.push('/'); diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index ba0b97b6088a8..11938a55181d3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -46,11 +46,13 @@ export const EditConnector = React.memo( onSubmit, selectedConnector, }: EditConnectorProps) => { + const initialState = { connectors }; const { form } = useForm({ - defaultValue: { connectors }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { setFieldValue, submit } = form; const [connectorHasChanged, setConnectorHasChanged] = useState(false); const onChangeConnector = useCallback( (connectorId) => { @@ -60,17 +62,18 @@ export const EditConnector = React.memo( ); const onCancelConnector = useCallback(() => { - form.setFieldValue('connector', selectedConnector); + setFieldValue('connector', selectedConnector); setConnectorHasChanged(false); - }, [form, selectedConnector]); + }, [selectedConnector, setFieldValue]); const onSubmitConnector = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); + const { isValid, data: newData } = await submit(); if (isValid && newData.connector) { onSubmit(newData.connector); setConnectorHasChanged(false); } - }, [form, onSubmit]); + }, [submit, onSubmit]); + return ( diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index 5f8404ca2dcc4..7bb10c743a418 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -42,20 +42,23 @@ const MyFlexGroup = styled(EuiFlexGroup)` export const TagList = React.memo( ({ disabled = false, isLoading, onSubmit, tags }: TagListProps) => { + const initialState = { tags }; const { form } = useForm({ - defaultValue: { tags }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const [isEditTags, setIsEditTags] = useState(false); const onSubmitTags = useCallback(async () => { - const { isValid, data: newData } = await form.submit(); + const { isValid, data: newData } = await submit(); if (isValid && newData.tags) { onSubmit(newData.tags); setIsEditTags(false); } - }, [form, onSubmit]); + }, [onSubmit, submit]); + const { tags: tagOptions } = useGetTags(); const [options, setOptions] = useState( tagOptions.map((label) => ({ diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index 0a8167049266f..da081fea5eac0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -6,7 +6,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import * as i18n from '../case_view/translations'; import { Markdown } from '../../../common/components/markdown'; @@ -18,9 +18,7 @@ import { MarkdownEditorForm } from '../../../common/components//markdown_editor/ import { useTimelineClick } from '../utils/use_timeline_click'; const ContentWrapper = styled.div` - ${({ theme }) => css` - padding: ${theme.eui.euiSizeM} ${theme.eui.euiSizeL}; - `} + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; `; interface UserActionMarkdownProps { @@ -37,11 +35,13 @@ export const UserActionMarkdown = ({ onChangeEditable, onSaveContent, }: UserActionMarkdownProps) => { + const initialState = { content }; const { form } = useForm({ - defaultValue: { content }, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( form, 'content' @@ -53,45 +53,43 @@ export const UserActionMarkdown = ({ const handleTimelineClick = useTimelineClick(); const handleSaveAction = useCallback(async () => { - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { onSaveContent(data.content); } onChangeEditable(id); - }, [form, id, onChangeEditable, onSaveContent]); + }, [id, onChangeEditable, onSaveContent, submit]); const renderButtons = useCallback( - ({ cancelAction, saveAction }) => { - return ( - - - - {i18n.CANCEL} - - - - - {i18n.SAVE} - - - - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [handleCancelAction, handleSaveAction] + ({ cancelAction, saveAction }) => ( + + + + {i18n.CANCEL} + + + + + {i18n.SAVE} + + + + ), + [] ); + return isEditable ? ( = ({ setForm, setStepData, }) => { - const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); + const initialState = defaultValues ?? stepAboutDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( defineRuleData?.index ?? [] ); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { getFields, submit } = form; const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid) { setStepData(RuleStep.aboutRule, data, isValid); setMyStepData({ ...data, isNew: false } as AboutStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setStepData, submit]); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.aboutRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setForm, form]); return isReadOnlyView && myStepData.name != null ? ( @@ -338,8 +323,8 @@ const StepAboutRuleComponent: FC = ({ {({ severity }) => { const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; - const severityField = form.getFields().severity; - const riskScoreField = form.getFields().riskScore; + const severityField = getFields().severity; + const riskScoreField = getFields().riskScore; if ( severityField.value !== severity && newRiskScore != null && diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index c7d70684b34cf..51e9291f31941 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -17,7 +17,6 @@ import { useFetchIndexPatterns } from '../../../containers/detection_engine/rule import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useMlCapabilities } from '../../../../common/components/ml_popover/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { filterRuleFieldsForType, RuleFields, @@ -109,58 +108,46 @@ const StepDefineRuleComponent: FC = ({ const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); - const [localRuleType, setLocalRuleType] = useState( - defaultValues?.ruleType || stepDefineDefaultValue.ruleType - ); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [myStepData, setMyStepData] = useState({ + const initialState = defaultValues ?? { ...stepDefineDefaultValue, index: indicesConfig ?? [], - }); + }; + const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); + const [myStepData, setMyStepData] = useState(initialState); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const clearErrors = useCallback(() => form.reset({ resetValues: false }), [form]); + const { getFields, reset, submit } = form; + const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]); const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await form.submit(); + const { isValid, data } = await submit(); if (isValid && setStepData) { setStepData(RuleStep.defineRule, data, isValid); setMyStepData({ ...data, isNew: false } as DefineStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); - - useEffect(() => { - const { isNew, ...values } = myStepData; - if (defaultValues != null && !deepEqual(values, defaultValues)) { - const newValues = { ...values, ...defaultValues, isNew: false }; - setMyStepData(newValues); - setFieldValue(form, schema, newValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues, setMyStepData, setFieldValue]); + }, [setStepData, submit]); useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.defineRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); const handleResetIndices = useCallback(() => { - const indexField = form.getFields().index; + const indexField = getFields().index; indexField.setValue(indicesConfig); - }, [form, indicesConfig]); + }, [getFields, indicesConfig]); const handleOpenTimelineSearch = useCallback(() => { setOpenTimelineSearch(true); @@ -281,11 +268,11 @@ const StepDefineRuleComponent: FC = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: defaultValues?.threshold?.field, + defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: defaultValues?.threshold?.value, + defaultValue: initialState.threshold.value, }, }} > diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 7005bfb25f4a6..7bf151adde5cc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -14,9 +14,7 @@ import { } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; -import deepEqual from 'fast-deep-equal'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { RuleStep, RuleStepProps, @@ -71,7 +69,8 @@ const StepRuleActionsComponent: FC = ({ setForm, actionMessageParams, }) => { - const [myStepData, setMyStepData] = useState(stepActionsDefaultValue); + const initialState = defaultValues ?? stepActionsDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const { services: { application, @@ -81,10 +80,11 @@ const StepRuleActionsComponent: FC = ({ const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; // TO DO need to make sure that logic is still valid const kibanaAbsoluteUrl = useMemo(() => { @@ -101,36 +101,21 @@ const StepRuleActionsComponent: FC = ({ async (enabled: boolean) => { if (setStepData) { setStepData(RuleStep.ruleActions, null, false); - const { isValid: newIsValid, data } = await form.submit(); + const { isValid: newIsValid, data } = await submit(); if (newIsValid) { setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); setMyStepData({ ...data, isNew: false } as ActionsStepRule); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [form] + [setStepData, submit] ); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.ruleActions, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [ myStepData, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index fa0f4dbd3668c..52f04f8423bec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -5,9 +5,7 @@ */ import React, { FC, memo, useCallback, useEffect, useState } from 'react'; -import deepEqual from 'fast-deep-equal'; -import { setFieldValue } from '../../../pages/detection_engine/rules/helpers'; import { RuleStep, RuleStepProps, @@ -40,45 +38,32 @@ const StepScheduleRuleComponent: FC = ({ setStepData, setForm, }) => { - const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); + const initialState = defaultValues ?? stepScheduleDefaultValue; + const [myStepData, setMyStepData] = useState(initialState); const { form } = useForm({ - defaultValue: myStepData, + defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); + const { submit } = form; const onSubmit = useCallback(async () => { if (setStepData) { setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); + const { isValid: newIsValid, data } = await submit(); if (newIsValid) { setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [setStepData, submit]); useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !deepEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - setFieldValue(form, schema, myDefaultValues); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultValues]); - - useEffect(() => { - if (setForm != null) { + if (setForm) { setForm(RuleStep.scheduleRule, form); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, setForm]); return isReadOnlyView && myStepData != null ? ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index f6e13786e98d0..6ba65ceca8fe9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -109,10 +109,10 @@ const CreateRulePageComponent: React.FC = () => { [RuleStep.ruleActions]: null, }); const stepsData = useRef>({ - [RuleStep.defineRule]: { isValid: false, data: {} }, - [RuleStep.aboutRule]: { isValid: false, data: {} }, - [RuleStep.scheduleRule]: { isValid: false, data: {} }, - [RuleStep.ruleActions]: { isValid: false, data: {} }, + [RuleStep.defineRule]: { isValid: false, data: undefined }, + [RuleStep.aboutRule]: { isValid: false, data: undefined }, + [RuleStep.scheduleRule]: { isValid: false, data: undefined }, + [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ [RuleStep.defineRule]: false, @@ -123,7 +123,7 @@ const CreateRulePageComponent: React.FC = () => { const [{ isLoading, isSaved }, setRule] = usePersistRule(); const actionMessageParams = useMemo( () => - getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule).ruleType), + getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), // eslint-disable-next-line react-hooks/exhaustive-deps [stepsData.current['define-rule'].data] ); @@ -335,9 +335,7 @@ const CreateRulePageComponent: React.FC = () => { { { , - schema: FormSchema, - defaultValues: unknown -) => - Object.keys(schema).forEach((key) => { - const val = get(key, defaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); export const redirectToDetections = ( isSignalIndexExists: boolean | null, From 6068285c378d3b83cc97b8b20ca47abb273e2ba3 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Wed, 15 Jul 2020 08:50:36 -0700 Subject: [PATCH 13/52] Removes timestamp_field from data_stream (#71727) https://github.com/elastic/kibana/issues/71670 Caused by https://github.com/elastic/elasticsearch/pull/59317 Signed-off-by: Tyler Smalley --- .../ingest_manager/common/types/models/epm.ts | 4 +--- .../template/__snapshots__/template.test.ts.snap | 12 +++--------- .../services/epm/elasticsearch/template/template.ts | 4 +--- x-pack/test/api_integration/apis/fleet/index.js | 4 +--- .../apis/management/index_management/data_streams.ts | 3 +-- .../apis/epm/install.ts | 4 +--- .../apps/endpoint/policy_details.ts | 4 +--- .../apps/endpoint/policy_list.ts | 4 +--- 8 files changed, 10 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/types/models/epm.ts b/x-pack/plugins/ingest_manager/common/types/models/epm.ts index ab6a6c73843c5..6ec5b73eaa43e 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/epm.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/epm.ts @@ -276,9 +276,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: { - timestamp_field: string; - }; + data_stream: object; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index 7437321163749..47817a29b2a17 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -91,9 +91,7 @@ exports[`tests loading base.yml: base.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { @@ -196,9 +194,7 @@ exports[`tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { @@ -1685,9 +1681,7 @@ exports[`tests loading system.yml: system.yml 1`] = ` }, "aliases": {} }, - "data_stream": { - "timestamp_field": "@timestamp" - }, + "data_stream": {}, "composed_of": [], "_meta": { "package": { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index b907c735d2630..cb1d692c43844 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -308,9 +308,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: { - timestamp_field: '@timestamp', - }, + data_stream: {}, composed_of: composedOfTemplates, _meta: { package: { diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/api_integration/apis/fleet/index.js index ec80b9aed4be0..df81b826132a9 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/api_integration/apis/fleet/index.js @@ -5,9 +5,7 @@ */ export default function loadTests({ loadTestFile }) { - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('Fleet Endpoints', () => { + describe('Fleet Endpoints', () => { loadTestFile(require.resolve('./setup')); loadTestFile(require.resolve('./delete_agent')); loadTestFile(require.resolve('./list_agent')); diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index 9f5c2a3de07bf..f8ddca374209b 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -51,8 +51,7 @@ export default function ({ getService }: FtrProviderContext) { await deleteComposableIndexTemplate(name); }; - // Temporarily skipping tests until ES snapshot is updated - describe.skip('Data streams', function () { + describe('Data streams', function () { describe('Get', () => { const testDataStreamName = 'test-data-stream'; diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts index f2ca98ca39a0b..f73ba56c172c4 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts @@ -21,9 +21,7 @@ export default function ({ getService }: FtrProviderContext) { const mappingsPackage = 'overrides-0.1.0'; const server = dockerServers.get('registry'); - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('installs packages that include settings and mappings overrides', async () => { + describe('installs packages that include settings and mappings overrides', async () => { after(async () => { if (server.enabled) { // remove the package just in case it being installed will affect other tests diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 0c9a86449506b..cf76f297d83be 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -19,9 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const policyTestResources = getService('policyTestResources'); - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('When on the Endpoint Policy Details Page', function () { + describe('When on the Endpoint Policy Details Page', function () { this.tags(['ciGroup7']); describe('with an invalid policy id', () => { diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 5b4a5cca108f9..57321ab4cd911 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -19,9 +19,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const policyTestResources = getService('policyTestResources'); const RELATIVE_DATE_FORMAT = /\d (?:seconds|minutes) ago/i; - // Temporarily skipped to promote snapshot - // Re-enabled in https://github.com/elastic/kibana/pull/71727 - describe.skip('When on the Endpoint Policy List', function () { + describe('When on the Endpoint Policy List', function () { this.tags(['ciGroup7']); before(async () => { await pageObjects.policy.navigateToPolicyList(); From 59f3722902dd38ddb8a384dcafde64089acdefcd Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 15 Jul 2020 17:24:31 +0100 Subject: [PATCH 14/52] [ML] Fix management section access denied (#71841) --- .../application/capabilities/check_capabilities.ts | 3 --- .../components/jobs_list_page/jobs_list_page.tsx | 11 ++++++++--- .../public/application/management/management_urls.ts | 1 - 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts index 56b372ff39919..653eca126006d 100644 --- a/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts +++ b/x-pack/plugins/ml/public/application/capabilities/check_capabilities.ts @@ -10,7 +10,6 @@ import { hasLicenseExpired } from '../license'; import { MlCapabilities, getDefaultCapabilities } from '../../../common/types/capabilities'; import { getCapabilities, getManageMlCapabilities } from './get_capabilities'; -import { ACCESS_DENIED_PATH } from '../management/management_urls'; let _capabilities: MlCapabilities = getDefaultCapabilities(); @@ -25,12 +24,10 @@ export function checkGetManagementMlJobsResolver() { if (isManageML === true && isPlatinumOrTrialLicense === true) { return resolve({ mlFeatureEnabledInSpace }); } else { - window.location.href = ACCESS_DENIED_PATH; return reject(); } }) .catch((e) => { - window.location.href = ACCESS_DENIED_PATH; return reject(); }); }); diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index e3c45c6cd0b04..33bb78c51e013 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -27,6 +27,7 @@ import { getDocLinks } from '../../../../util/dependency_cache'; // @ts-ignore undeclared module import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_view/index'; import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; +import { AccessDeniedPage } from '../access_denied_page'; interface Tab { id: string; @@ -68,6 +69,7 @@ function getTabs(isMlEnabledInSpace: boolean): Tab[] { export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { const [initialized, setInitialized] = useState(false); + const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = getTabs(isMlEnabledInSpace); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); @@ -76,12 +78,11 @@ export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { const check = async () => { try { const checkPrivilege = await checkGetManagementMlJobsResolver(); - setInitialized(true); setIsMlEnabledInSpace(checkPrivilege.mlFeatureEnabledInSpace); } catch (e) { - // Silent fail, `checkGetManagementMlJobs()` should redirect when - // there are insufficient permissions. + setAccessDenied(true); } + setInitialized(true); }; useEffect(() => { @@ -120,6 +121,10 @@ export const JobsListPage: FC<{ coreStart: CoreStart }> = ({ coreStart }) => { ); } + if (accessDenied) { + return ; + } + return ( diff --git a/x-pack/plugins/ml/public/application/management/management_urls.ts b/x-pack/plugins/ml/public/application/management/management_urls.ts index f346940e91ed0..1a83fd2fb4d42 100644 --- a/x-pack/plugins/ml/public/application/management/management_urls.ts +++ b/x-pack/plugins/ml/public/application/management/management_urls.ts @@ -9,4 +9,3 @@ type Path = string; export const MANAGEMENT_PATH: Path = '/management'; export const ML_PATH: Path = `${MANAGEMENT_PATH}/ml`; export const JOBS_LIST_PATH: Path = `${ML_PATH}/jobs_list`; -export const ACCESS_DENIED_PATH: Path = `${ML_PATH}/access_denied`; From 4e13c6880abadbaa1e53f1ff853a144961f0de86 Mon Sep 17 00:00:00 2001 From: Andrea Del Rio Date: Wed, 15 Jul 2020 09:27:29 -0700 Subject: [PATCH 15/52] [Discover] Add wrapping to field list on sidebar (#71312) --- .../components/sidebar/discover_field.tsx | 24 ++++++++++-------- .../components/sidebar/discover_sidebar.scss | 25 ++++++------------- .../components/sidebar/discover_sidebar.tsx | 3 ++- 3 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index 5f40c55e30e7e..724908281146d 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -17,7 +17,7 @@ * under the License. */ import React from 'react'; -import { EuiButton, EuiText } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; import { FieldIcon } from '../../../../../kibana_react/public'; @@ -108,6 +108,13 @@ export function DiscoverField({ } }; + function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; + } + return ( <>
- - - {useShortDots ? shortenDottedString(field.name) : field.displayName} - + + {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} {field.name !== '_source' && !selected && ( diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index ae7e915f09773..07efd64752c84 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -23,13 +23,6 @@ margin-bottom: 0; } -.dscFieldList--selected, -.dscFieldList--unpopular, -.dscFieldList--popular { - padding-left: $euiSizeS; - padding-right: $euiSizeS; -} - .dscFieldListHeader { padding: $euiSizeS $euiSizeS 0 $euiSizeS; background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); @@ -40,8 +33,7 @@ } .dscFieldChooser { - padding-left: $euiSizeS !important; - padding-right: $euiSizeS !important; + padding-left: $euiSize; } .dscFieldChooser__toggle { @@ -55,12 +47,12 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0 2px; cursor: pointer; font-size: $euiFontSizeXS; border-top: solid 1px transparent; border-bottom: solid 1px transparent; line-height: normal; + margin-bottom: $euiSizeXS * 0.5; &:hover, &:focus { @@ -72,28 +64,25 @@ .dscSidebarItem--active { border-top: 1px solid $euiColorLightShade; - background: shade($euiColorLightestShade, 5%); color: $euiColorFullShade; - .euiText { - font-weight: bold; - } } .dscSidebarField { - padding: $euiSizeXS 0; + padding: $euiSizeXS; display: flex; - align-items: flex-start; + align-items: center; max-width: 100%; - margin: 0; width: 100%; border: none; - border-radius: 0; + border-radius: $euiBorderRadius - 1px; text-align: left; } .dscSidebarField__name { margin-left: $euiSizeS; flex-grow: 1; + word-break: break-word; + padding-right: 1px; } .dscSidebarField__fieldIcon { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index 96e04c13d70e9..e8ed8b80da3bb 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -19,7 +19,7 @@ import './discover_sidebar.scss'; import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiTitle } from '@elastic/eui'; +import { EuiButtonIcon, EuiTitle, EuiSpacer } from '@elastic/eui'; import { sortBy } from 'lodash'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { DiscoverField } from './discover_field'; @@ -199,6 +199,7 @@ export function DiscoverSidebar({ /> +
diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx index 7cc049c107b87..1d60b0b5cbbee 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/fields_accordion.tsx @@ -6,12 +6,14 @@ import './datapanel.scss'; import React, { memo, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiText, EuiNotificationBadge, EuiSpacer, EuiAccordion, EuiLoadingSpinner, + EuiIconTip, } from '@elastic/eui'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { IndexPatternField } from './types'; @@ -44,6 +46,7 @@ export interface FieldsAccordionProps { fieldProps: FieldItemSharedProps; renderCallout: JSX.Element; exists: boolean; + showExistenceFetchError?: boolean; } export const InnerFieldsAccordion = function InnerFieldsAccordion({ @@ -58,6 +61,7 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({ fieldProps, renderCallout, exists, + showExistenceFetchError, }: FieldsAccordionProps) { const renderField = useCallback( (field: IndexPatternField) => { @@ -78,7 +82,18 @@ export const InnerFieldsAccordion = function InnerFieldsAccordion({
} extraAction={ - hasLoaded ? ( + showExistenceFetchError ? ( + + ) : hasLoaded ? ( {fieldsCount} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index 5776691fbcc7f..27904a0f23f16 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -13,7 +13,7 @@ import { changeLayerIndexPattern, syncExistingFields, } from './loader'; -import { IndexPatternPersistedState, IndexPatternPrivateState } from './types'; +import { IndexPatternPersistedState, IndexPatternPrivateState, IndexPatternField } from './types'; import { documentField } from './document_field'; jest.mock('./operations'); @@ -642,7 +642,11 @@ describe('loader', () => { await syncExistingFields({ dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, - indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + indexPatterns: [ + { id: 'a', title: 'a', fields: [] }, + { id: 'b', title: 'a', fields: [] }, + { id: 'c', title: 'a', fields: [] }, + ], setState, dslQuery, showNoDataPopover: jest.fn(), @@ -662,6 +666,7 @@ describe('loader', () => { expect(newState).toEqual({ foo: 'bar', isFirstExistenceFetch: false, + existenceFetchFailed: false, existingFields: { a: { a_field_1: true, a_field_2: true }, b: { b_field_1: true, b_field_2: true }, @@ -687,7 +692,11 @@ describe('loader', () => { const args = { dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, fetchJson, - indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + indexPatterns: [ + { id: 'a', title: 'a', fields: [] }, + { id: 'b', title: 'a', fields: [] }, + { id: 'c', title: 'a', fields: [] }, + ], setState, dslQuery, showNoDataPopover: jest.fn(), @@ -702,5 +711,45 @@ describe('loader', () => { await syncExistingFields({ ...args, isFirstExistenceFetch: true }); expect(showNoDataPopover).not.toHaveBeenCalled(); }); + + it('should set all fields to available and existence error flag if the request fails', async () => { + const setState = jest.fn(); + const fetchJson = (jest.fn((path: string) => { + return new Promise((resolve, reject) => { + reject(new Error()); + }); + }) as unknown) as HttpHandler; + + const args = { + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + fetchJson, + indexPatterns: [ + { + id: 'a', + title: 'a', + fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], + }, + ], + setState, + dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, + }; + + await syncExistingFields(args); + + const [fn] = setState.mock.calls[0]; + const newState = fn({ + foo: 'bar', + existingFields: {}, + }) as IndexPatternPrivateState; + + expect(newState.existenceFetchFailed).toEqual(true); + expect(newState.existingFields.a).toEqual({ + field1: true, + field2: true, + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index e995c7317b5d8..20e7bec6db131 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -246,7 +246,12 @@ export async function syncExistingFields({ showNoDataPopover, }: { dateRange: DateRange; - indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; + indexPatterns: Array<{ + id: string; + title: string; + fields: IndexPatternField[]; + timeFieldName?: string | null; + }>; fetchJson: HttpSetup['post']; setState: SetState; isFirstExistenceFetch: boolean; @@ -254,41 +259,53 @@ export async function syncExistingFields({ dslQuery: object; showNoDataPopover: () => void; }) { - const emptinessInfo = await Promise.all( - indexPatterns.map((pattern) => { - const body: Record = { - dslQuery, - fromDate: dateRange.fromDate, - toDate: dateRange.toDate, - }; - - if (pattern.timeFieldName) { - body.timeFieldName = pattern.timeFieldName; - } + const existenceRequests = indexPatterns.map((pattern) => { + const body: Record = { + dslQuery, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + }; - return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { - body: JSON.stringify(body), - }) as Promise; - }) - ); + if (pattern.timeFieldName) { + body.timeFieldName = pattern.timeFieldName; + } - if (isFirstExistenceFetch) { - const fieldsCurrentIndexPattern = emptinessInfo.find( - (info) => info.indexPatternTitle === currentIndexPatternTitle - ); - if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { - showNoDataPopover(); + return fetchJson(`${BASE_API_URL}/existing_fields/${pattern.id}`, { + body: JSON.stringify(body), + }) as Promise; + }); + + try { + const emptinessInfo = await Promise.all(existenceRequests); + if (isFirstExistenceFetch) { + const fieldsCurrentIndexPattern = emptinessInfo.find( + (info) => info.indexPatternTitle === currentIndexPatternTitle + ); + if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { + showNoDataPopover(); + } } - } - setState((state) => ({ - ...state, - isFirstExistenceFetch: false, - existingFields: emptinessInfo.reduce((acc, info) => { - acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); - return acc; - }, state.existingFields), - })); + setState((state) => ({ + ...state, + isFirstExistenceFetch: false, + existenceFetchFailed: false, + existingFields: emptinessInfo.reduce((acc, info) => { + acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); + return acc; + }, state.existingFields), + })); + } catch (e) { + // show all fields as available if fetch failed + setState((state) => ({ + ...state, + existenceFetchFailed: true, + existingFields: indexPatterns.reduce((acc, pattern) => { + acc[pattern.title] = booleanMap(pattern.fields.map((field) => field.name)); + return acc; + }, state.existingFields), + })); + } } function booleanMap(keys: string[]) { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index b7beb67196add..2a9b3f452d991 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -52,6 +52,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { */ existingFields: Record>; isFirstExistenceFetch: boolean; + existenceFetchFailed?: boolean; }; export interface IndexPatternRef { From 61d12a1fe4c902a751ada4fdcba22f005d97cc8e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 16 Jul 2020 13:48:43 +0300 Subject: [PATCH 50/52] [Security Solution] Refactor for inclusive language (#71858) --- .../draggable_wrapper_hover_content.test.tsx | 26 +++++++++---------- .../components/drag_and_drop/helpers.test.ts | 6 ++--- .../components/drag_and_drop/helpers.ts | 6 ++--- .../public/common/components/links/index.tsx | 6 ++--- .../public/resolver/store/index.ts | 4 +-- .../system/generic_file_details.test.tsx | 4 +-- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index 3f06a8168b5ce..8e76a88572e42 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -436,14 +436,14 @@ describe('DraggableWrapperHoverContent', () => { expect(wrapper.find('[data-test-subj="show-top-field"]').first().exists()).toBe(true); }); - test(`it renders the 'Show top field' button when showTopN is false and a whitelisted signal field is provided`, async () => { - const whitelistedField = 'signal.rule.name'; + test(`it renders the 'Show top field' button when showTopN is false and a allowlisted signal field is provided`, async () => { + const allowlistedField = 'signal.rule.name'; const wrapper = mount( @@ -473,13 +473,13 @@ describe('DraggableWrapperHoverContent', () => { }); test(`it should invokes goGetTimelineId when user is over the 'Show top field' button`, () => { - const whitelistedField = 'signal.rule.name'; + const allowlistedField = 'signal.rule.name'; const wrapper = mount( @@ -491,13 +491,13 @@ describe('DraggableWrapperHoverContent', () => { }); test(`invokes the toggleTopN function when the 'Show top field' button is clicked`, async () => { - const whitelistedField = 'signal.rule.name'; + const allowlistedField = 'signal.rule.name'; const wrapper = mount( @@ -512,13 +512,13 @@ describe('DraggableWrapperHoverContent', () => { }); test(`it does NOT render the Top N histogram when when showTopN is false`, async () => { - const whitelistedField = 'signal.rule.name'; + const allowlistedField = 'signal.rule.name'; const wrapper = mount( @@ -532,13 +532,13 @@ describe('DraggableWrapperHoverContent', () => { }); test(`it does NOT render the 'Show top field' button when showTopN is true`, async () => { - const whitelistedField = 'signal.rule.name'; + const allowlistedField = 'signal.rule.name'; const wrapper = mount( @@ -551,13 +551,13 @@ describe('DraggableWrapperHoverContent', () => { }); test(`it renders the Top N histogram when when showTopN is true`, async () => { - const whitelistedField = 'signal.rule.name'; + const allowlistedField = 'signal.rule.name'; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts index afdfde6e08224..68032fb7dc512 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.test.ts @@ -658,7 +658,7 @@ describe('helpers', () => { ).toBe(true); }); - test('it returns true for a whitelisted non-BrowserField', () => { + test('it returns true for a allowlisted non-BrowserField', () => { expect( allowTopN({ browserField: undefined, @@ -717,11 +717,11 @@ describe('helpers', () => { ).toBe(false); }); - test('it returns false for a non-whitelisted field when a BrowserField is not provided', () => { + test('it returns false for a non-allowlisted field when a BrowserField is not provided', () => { expect( allowTopN({ browserField: undefined, - fieldName: 'non-whitelisted', + fieldName: 'non-allowlisted', }) ).toBe(false); }); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index ba328eff62e51..132ab054c3afd 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -264,8 +264,8 @@ export const allowTopN = ({ 'string', ].includes(fieldType); - // TODO: remove this explicit whitelist when the ECS documentation includes alerts - const isWhitelistedNonBrowserField = [ + // TODO: remove this explicit allowlist when the ECS documentation includes alerts + const isAllowlistedNonBrowserField = [ 'signal.ancestors.depth', 'signal.ancestors.id', 'signal.ancestors.rule', @@ -336,7 +336,7 @@ export const allowTopN = ({ 'signal.status', ].includes(fieldName); - return isWhitelistedNonBrowserField || (isAggregatable && isAllowedType); + return isAllowlistedNonBrowserField || (isAggregatable && isAllowedType); }; export const getTimelineIdFromColumnDroppableId = (droppableId: string) => diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 3b92faff91517..4a9ad94d8f36d 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -78,7 +78,7 @@ const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: }; export const HostDetailsLink = React.memo(HostDetailsLinkComponent); -const whitelistUrlSchemes = ['http://', 'https://']; +const allowedUrlSchemes = ['http://', 'https://']; export const ExternalLink = React.memo<{ url: string; children?: React.ReactNode; @@ -96,8 +96,8 @@ export const ExternalLink = React.memo<{ const lastVisibleItemIndex = overflowIndexStart - 1; const lastItemIndex = allItemsLimit - 1; const lastIndexToShow = Math.max(0, Math.min(lastVisibleItemIndex, lastItemIndex)); - const inWhitelist = whitelistUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); - return url && inWhitelist && !isUrlInvalid(url) && children ? ( + const inAllowlist = allowedUrlSchemes.some((scheme) => url.indexOf(scheme) === 0); + return url && inAllowlist && !isUrlInvalid(url) && children ? ( {children} diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index 9809e443d2d13..d9e750241ced1 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -16,10 +16,10 @@ import { ResolverAction } from './actions'; export const storeFactory = ( context?: KibanaReactContextValue ): Store => { - const actionsBlacklist: Array = ['userMovedPointer']; + const actionsDenylist: Array = ['userMovedPointer']; const composeEnhancers = composeWithDevTools({ name: 'Resolver', - actionsBlacklist, + actionsBlacklist: actionsDenylist, }); const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context)); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx index 52c232f377f79..2c207ea8a1562 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/system/generic_file_details.test.tsx @@ -1231,8 +1231,8 @@ describe('SystemGenericFileDetails', () => { }); }); - test('it does NOT render the text "via" when eventAction is not a whitelisted action', () => { - const eventAction = 'a_non_whitelisted_event_action'; + test('it does NOT render the text "via" when eventAction is not a allowlisted action', () => { + const eventAction = 'a_non_allowlisted_event_action'; const wrapper = mount( From 5ecf31712a7e3ce33ca3b455a98e6de308599930 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 16 Jul 2020 13:25:53 +0200 Subject: [PATCH 51/52] [ML] Deprecate MlInMemoryTable. (#65039) Now that EUI is fully migrated to TypeScript, we can remove our own typed version of InMemoryTable. --- .../components/ml_in_memory_table/index.ts | 8 - .../ml_in_memory_table/ml_in_memory_table.tsx | 108 ---------- .../components/ml_in_memory_table/types.ts | 202 ------------------ .../analytics_list/_analytics_table.scss | 3 - .../analytics_list/analytics_list.tsx | 185 ++++------------ .../components/analytics_list/common.ts | 13 +- .../analytics_service/get_analytics.ts | 10 +- .../jobs_list_page/_analytics_table.scss | 4 - .../components/analytics_panel/table.tsx | 37 ++-- .../anomaly_detection_panel/table.tsx | 38 ++-- 10 files changed, 106 insertions(+), 502 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/components/ml_in_memory_table/index.ts delete mode 100644 x-pack/plugins/ml/public/application/components/ml_in_memory_table/ml_in_memory_table.tsx delete mode 100644 x-pack/plugins/ml/public/application/components/ml_in_memory_table/types.ts diff --git a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/index.ts b/x-pack/plugins/ml/public/application/components/ml_in_memory_table/index.ts deleted file mode 100644 index bbd793696e005..0000000000000 --- a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/index.ts +++ /dev/null @@ -1,8 +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. - */ - -export { ProgressBar, mlInMemoryTableFactory } from './ml_in_memory_table'; -export * from './types'; diff --git a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/ml_in_memory_table.tsx b/x-pack/plugins/ml/public/application/components/ml_in_memory_table/ml_in_memory_table.tsx deleted file mode 100644 index 7caaadf65d6da..0000000000000 --- a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/ml_in_memory_table.tsx +++ /dev/null @@ -1,108 +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. - */ - -// This component extends EuiInMemoryTable with some -// fixes and TS specs until the changes become available upstream. - -import React, { Fragment } from 'react'; - -import { EuiProgress } from '@elastic/eui'; - -// The built in loading progress bar of EuiInMemoryTable causes a full DOM replacement -// of the table and doesn't play well with auto-refreshing. That's why we're displaying -// our own progress bar on top of the table. `EuiProgress` after `isLoading` displays -// the loading indicator. The variation after `!isLoading` displays an empty progress -// bar fixed to 0%. Without it, the display would vertically jump when showing/hiding -// the progress bar. -export const ProgressBar = ({ isLoading = false }) => { - return ( - - {isLoading && } - {!isLoading && ( - - )} - - ); -}; - -// copied from EUI to be available to the extended getDerivedStateFromProps() -function findColumnByProp(columns: any, prop: any, value: any) { - for (let i = 0; i < columns.length; i++) { - const column = columns[i]; - if (column[prop] === value) { - return column; - } - } -} - -// copied from EUI to be available to the extended getDerivedStateFromProps() -const getInitialSorting = (columns: any, sorting: any) => { - if (!sorting || !sorting.sort) { - return { - sortName: undefined, - sortDirection: undefined, - }; - } - - const { field: sortable, direction: sortDirection } = sorting.sort; - - // sortable could be a column's `field` or its `name` - // for backwards compatibility `field` must be checked first - let sortColumn = findColumnByProp(columns, 'field', sortable); - if (sortColumn == null) { - sortColumn = findColumnByProp(columns, 'name', sortable); - } - - if (sortColumn == null) { - return { - sortName: undefined, - sortDirection: undefined, - }; - } - - const sortName = sortColumn.name; - - return { - sortName, - sortDirection, - }; -}; - -import { mlInMemoryTableBasicFactory } from './types'; - -export function mlInMemoryTableFactory() { - const MlInMemoryTableBasic = mlInMemoryTableBasicFactory(); - - return class MlInMemoryTable extends MlInMemoryTableBasic { - static getDerivedStateFromProps(nextProps: any, prevState: any) { - const derivedState = { - ...prevState.prevProps, - pageIndex: nextProps.pagination.initialPageIndex, - pageSize: nextProps.pagination.initialPageSize, - }; - - if (nextProps.items !== prevState.prevProps.items) { - Object.assign(derivedState, { - prevProps: { - items: nextProps.items, - }, - }); - } - - const { sortName, sortDirection } = getInitialSorting(nextProps.columns, nextProps.sorting); - if ( - sortName !== prevState.prevProps.sortName || - sortDirection !== prevState.prevProps.sortDirection - ) { - Object.assign(derivedState, { - sortName, - sortDirection, - }); - } - return derivedState; - } - }; -} diff --git a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/types.ts b/x-pack/plugins/ml/public/application/components/ml_in_memory_table/types.ts deleted file mode 100644 index 05b941f2544b4..0000000000000 --- a/x-pack/plugins/ml/public/application/components/ml_in_memory_table/types.ts +++ /dev/null @@ -1,202 +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 { Component, HTMLAttributes, ReactElement, ReactNode } from 'react'; - -import { Direction, CommonProps, EuiInMemoryTable } from '@elastic/eui'; - -// Not using an enum here because the original HorizontalAlignment is also a union type of string. -type HorizontalAlignment = 'left' | 'center' | 'right'; - -type SortableFunc = (item: T) => any; -type Sortable = boolean | SortableFunc; -type DATA_TYPES = any; -type FooterFunc = (payload: { items: T[]; pagination: any }) => ReactNode; -type RenderFunc = (value: any, record?: any) => ReactNode; -export interface FieldDataColumnType { - field: string; - name: ReactNode; - description?: string; - dataType?: DATA_TYPES; - width?: string; - sortable?: Sortable; - align?: HorizontalAlignment; - truncateText?: boolean; - render?: RenderFunc; - footer?: string | ReactElement | FooterFunc; - textOnly?: boolean; - scope?: 'col' | 'row' | 'colgroup' | 'rowgroup'; - 'data-test-subj'?: string; -} - -export interface ComputedColumnType { - render: RenderFunc; - name?: ReactNode; - description?: string; - sortable?: (item: T) => any; - width?: string; - truncateText?: boolean; - 'data-test-subj'?: string; -} - -type ICON_TYPES = any; -type IconTypesFunc = (item: T) => ICON_TYPES; // (item) => oneOf(ICON_TYPES) -type BUTTON_ICON_COLORS = any; -type ButtonIconColorsFunc = (item: T) => BUTTON_ICON_COLORS; // (item) => oneOf(ICON_BUTTON_COLORS) -interface DefaultItemActionType { - type?: 'icon' | 'button'; - name: ReactNode; - description: string; - onClick?(item: T): void; - href?: string; - target?: string; - available?(item: T): boolean; - enabled?(item: T): boolean; - isPrimary?: boolean; - icon?: ICON_TYPES | IconTypesFunc; // required when type is 'icon' - color?: BUTTON_ICON_COLORS | ButtonIconColorsFunc; -} - -interface CustomItemActionType { - render(item: T, enabled: boolean): ReactNode; - available?(item: T): boolean; - enabled?(item: T): boolean; - isPrimary?: boolean; -} - -export interface ExpanderColumnType extends ComputedColumnType { - align?: HorizontalAlignment; - isExpander: true; -} - -type SupportedItemActionType = DefaultItemActionType | CustomItemActionType; - -export interface ActionsColumnType { - actions: Array>; - name?: ReactNode; - description?: string; - width?: string; -} - -export type ColumnType = - | ActionsColumnType - | ComputedColumnType - | ExpanderColumnType - | FieldDataColumnType; - -type QueryType = any; - -interface Schema { - strict?: boolean; - fields?: Record; - flags?: string[]; -} - -interface SearchBoxConfigPropTypes { - placeholder?: string; - incremental?: boolean; - schema?: Schema; -} - -interface Box { - placeholder?: string; - incremental?: boolean; - // here we enable the user to just assign 'true' to the schema, in which case - // we will auto-generate it out of the columns configuration - schema?: boolean | SearchBoxConfigPropTypes['schema']; -} - -type SearchFiltersFiltersType = any; - -interface ExecuteQueryOptions { - defaultFields: string[]; - isClauseMatcher: () => void; - explain: boolean; -} - -type SearchType = - | boolean - | { - toolsLeft?: ReactNode; - toolsRight?: ReactNode; - defaultQuery?: QueryType; - box?: Box; - filters?: SearchFiltersFiltersType; - onChange?: (arg: any) => void; - executeQueryOptions?: ExecuteQueryOptions; - }; - -interface PageSizeOptions { - pageSizeOptions: number[]; -} -interface InitialPageOptions extends PageSizeOptions { - initialPageIndex: number; - initialPageSize: number; -} -type PaginationProp = boolean | PageSizeOptions | InitialPageOptions; - -export enum SORT_DIRECTION { - ASC = 'asc', - DESC = 'desc', -} -export type SortDirection = SORT_DIRECTION.ASC | SORT_DIRECTION.DESC | 'asc' | 'desc'; -interface SortFields { - field: string; - direction: SortDirection | Direction; -} -export interface Sorting { - sort?: SortFields; -} -export type SortingPropType = - | boolean - | { - sort: SortFields; - }; - -type SelectionType = any; - -export interface OnTableChangeArg extends Sorting { - page?: { index: number; size: number }; -} - -type ItemIdTypeFunc = (item: T) => string; -type ItemIdType = - | string // the name of the item id property - | ItemIdTypeFunc; - -export type EuiInMemoryTableProps = CommonProps & { - columns: Array>; - hasActions?: boolean; - isExpandable?: boolean; - isSelectable?: boolean; - items?: T[]; - loading?: boolean; - message?: HTMLAttributes; - error?: string; - compressed?: boolean; - search?: SearchType; - pagination?: PaginationProp; - sorting?: SortingPropType; - // Set `allowNeutralSort` to false to force column sorting. Defaults to true. - allowNeutralSort?: boolean; - responsive?: boolean; - selection?: SelectionType; - itemId?: ItemIdType; - itemIdToExpandedRowMap?: Record; - rowProps?: (item: T) => void | Record; - cellProps?: () => void | Record; - onTableChange?: (arg: OnTableChangeArg) => void; -}; - -type EuiInMemoryTableType = typeof EuiInMemoryTable; - -interface ComponentWithConstructor extends EuiInMemoryTableType { - new (): Component; -} - -export function mlInMemoryTableBasicFactory() { - return EuiInMemoryTable as ComponentWithConstructor>; -} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss index 4472e6dbf64f3..f8071c9210f9c 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/_analytics_table.scss @@ -17,9 +17,6 @@ animation: none !important; } } -.mlAnalyticsProgressBar { - margin-bottom: $euiSizeM; -} .mlTaskStateBadge, .mlTaskModeBadge { max-width: 100px; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index e2298108ddc4b..7b5c714c236b3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -9,17 +9,18 @@ import React, { FC, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { + Direction, EuiButtonEmpty, EuiCallOut, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, + EuiInMemoryTable, + EuiSearchBarProps, EuiSpacer, - EuiSearchBar, } from '@elastic/eui'; import { - getAnalysisType, DataFrameAnalyticsId, useRefreshAnalyticsList, ANALYSIS_CONFIG_TYPE, @@ -31,22 +32,10 @@ import { DataFrameAnalyticsListRow, ItemIdToExpandedRowMap, DATA_FRAME_TASK_STATE, - Query, - Clause, - TermClause, - FieldClause, } from './common'; import { getAnalyticsFactory } from '../../services/analytics_service'; import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; -import { stringMatch } from '../../../../../util/string_utils'; -import { - ProgressBar, - mlInMemoryTableFactory, - OnTableChangeArg, - SortDirection, - SORT_DIRECTION, -} from '../../../../../components/ml_in_memory_table'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; import { getSelectedJobIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; @@ -65,8 +54,6 @@ function getItemIdToExpandedRowMap( }, {} as ItemIdToExpandedRowMap); } -const MlInMemoryTable = mlInMemoryTableFactory(); - interface Props { isManagementTable?: boolean; isMlEnabledInSpace?: boolean; @@ -80,15 +67,13 @@ export const DataFrameAnalyticsList: FC = ({ const [isInitialized, setIsInitialized] = useState(false); const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [filterActive, setFilterActive] = useState(false); - const [queryText, setQueryText] = useState(''); + const [searchQueryText, setSearchQueryText] = useState(''); const [analytics, setAnalytics] = useState([]); const [analyticsStats, setAnalyticsStats] = useState( undefined ); - const [filteredAnalytics, setFilteredAnalytics] = useState([]); const [expandedRowItemIds, setExpandedRowItemIds] = useState([]); const [errorMessage, setErrorMessage] = useState(undefined); @@ -98,9 +83,8 @@ export const DataFrameAnalyticsList: FC = ({ const [pageSize, setPageSize] = useState(10); const [sortField, setSortField] = useState(DataFrameAnalyticsListColumn.id); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + const [sortDirection, setSortDirection] = useState('asc'); - const [jobIdSelected, setJobIdSelected] = useState(false); const disabled = !checkPermission('canCreateDataFrameAnalytics') || !checkPermission('canStartStopDataFrameAnalytics'); @@ -114,18 +98,17 @@ export const DataFrameAnalyticsList: FC = ({ ); // Query text/job_id based on url but only after getAnalytics is done first - // jobIdSelected makes sure the query is only run once since analytics is being refreshed constantly - const selectedId = getSelectedJobIdFromUrl(window.location.href); + // selectedJobIdFromUrlInitialized makes sure the query is only run once since analytics is being refreshed constantly + const [selectedJobIdFromUrlInitialized, setSelectedJobIdFromUrlInitialized] = useState(false); useEffect(() => { - if (jobIdSelected === false && analytics.length > 0) { - if (selectedId !== undefined) { - setJobIdSelected(true); - setQueryText(selectedId); - const selectedIdQuery: Query = EuiSearchBar.Query.parse(selectedId); - onQueryChange({ query: selectedIdQuery, error: undefined }); + if (selectedJobIdFromUrlInitialized === false && analytics.length > 0) { + const selectedJobIdFromUrl = getSelectedJobIdFromUrl(window.location.href); + if (selectedJobIdFromUrl !== undefined) { + setSelectedJobIdFromUrlInitialized(true); + setSearchQueryText(selectedJobIdFromUrl); } } - }, [jobIdSelected, analytics]); + }, [selectedJobIdFromUrlInitialized, analytics]); // Subscribe to the refresh observable to trigger reloading the analytics list. useRefreshAnalyticsList({ @@ -133,94 +116,6 @@ export const DataFrameAnalyticsList: FC = ({ onRefresh: () => getAnalytics(true), }); - const onQueryChange = ({ query, error }: { query: Query; error: any }) => { - if (error) { - setSearchError(error.message); - } else { - let clauses: Clause[] = []; - if (query && query.ast !== undefined && query.ast.clauses !== undefined) { - clauses = query.ast.clauses; - } - if (clauses.length > 0) { - setQueryText(query.text); - setFilterActive(true); - filterAnalytics(clauses as Array); - } else { - setFilterActive(false); - } - setSearchError(undefined); - } - }; - - const filterAnalytics = (clauses: Array) => { - setIsLoading(true); - // keep count of the number of matches we make as we're looping over the clauses - // we only want to return analytics which match all clauses, i.e. each search term is ANDed - // { analytics-one: { analytics: { id: analytics-one, config: {}, state: {}, ... }, count: 0 }, analytics-two: {...} } - const matches: Record = analytics.reduce((p: Record, c) => { - p[c.id] = { - analytics: c, - count: 0, - }; - return p; - }, {}); - - clauses.forEach((c) => { - // the search term could be negated with a minus, e.g. -bananas - const bool = c.match === 'must'; - let ts: DataFrameAnalyticsListRow[]; - - if (c.type === 'term') { - // filter term based clauses, e.g. bananas - // match on id and description - // if the term has been negated, AND the matches - if (bool === true) { - ts = analytics.filter( - (d) => stringMatch(d.id, c.value) === bool // || - // stringMatch(d.config.description, c.value) === bool - ); - } else { - ts = analytics.filter( - (d) => stringMatch(d.id, c.value) === bool // && - // stringMatch(d.config.description, c.value) === bool - ); - } - } else { - // filter other clauses, i.e. the mode and status filters - if (Array.isArray(c.value)) { - if (c.field === 'job_type') { - ts = analytics.filter((d) => - (c.value as string).includes(getAnalysisType(d.config.analysis)) - ); - } else { - // the status value is an array of string(s) e.g. ['failed', 'stopped'] - ts = analytics.filter((d) => (c.value as string).includes(d.stats.state)); - } - } else { - ts = analytics.filter((d) => d.mode === c.value); - } - } - - ts.forEach((t) => matches[t.id].count++); - }); - - // loop through the matches and return only analytics which have match all the clauses - const filtered = Object.values(matches) - .filter((m) => (m && m.count) >= clauses.length) - .map((m) => m.analytics); - - let pageStart = pageIndex * pageSize; - if (pageStart >= filtered.length && filtered.length !== 0) { - // if the page start is larger than the number of items due to - // filters being applied, calculate a new page start - pageStart = Math.floor((filtered.length - 1) / pageSize) * pageSize; - setPageIndex(pageStart / pageSize); - } - - setFilteredAnalytics(filtered); - setIsLoading(false); - }; - const { columns, modals } = useColumns( expandedRowItemIds, setExpandedRowItemIds, @@ -231,30 +126,26 @@ export const DataFrameAnalyticsList: FC = ({ // Before the analytics have been loaded for the first time, display the loading indicator only. // Otherwise a user would see 'No data frame analytics found' during the initial loading. if (!isInitialized) { - return ; + return null; } if (typeof errorMessage !== 'undefined') { return ( - <> - - -
{JSON.stringify(errorMessage)}
-
- + +
{JSON.stringify(errorMessage)}
+
); } if (analytics.length === 0) { return ( <> - @@ -304,9 +195,20 @@ export const DataFrameAnalyticsList: FC = ({ hidePerPageOptions: false, }; - const search = { - query: queryText, - onChange: onQueryChange, + const handleSearchOnChange: EuiSearchBarProps['onChange'] = (search) => { + if (search.error !== null) { + setSearchError(search.error.message); + return false; + } + + setSearchError(undefined); + setSearchQueryText(search.queryText); + return true; + }; + + const search: EuiSearchBarProps = { + query: searchQueryText, + onChange: handleSearchOnChange, box: { incremental: true, }, @@ -326,7 +228,7 @@ export const DataFrameAnalyticsList: FC = ({ }, { type: 'field_value_selection', - field: 'state.state', + field: 'state', name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', { defaultMessage: 'Status', }), @@ -340,10 +242,10 @@ export const DataFrameAnalyticsList: FC = ({ ], }; - const onTableChange = ({ + const onTableChange: EuiInMemoryTable['onTableChange'] = ({ page = { index: 0, size: 10 }, - sort = { field: DataFrameAnalyticsListColumn.id, direction: SORT_DIRECTION.ASC }, - }: OnTableChangeArg) => { + sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' }, + }) => { const { index, size } = page; setPageIndex(index); setPageSize(size); @@ -379,7 +281,7 @@ export const DataFrameAnalyticsList: FC = ({
- = ({ hasActions={false} isExpandable={true} isSelectable={false} - items={filterActive ? filteredAnalytics : analytics} + items={analytics} itemId={DataFrameAnalyticsListColumn.id} itemIdToExpandedRowMap={itemIdToExpandedRowMap} + loading={isLoading} onTableChange={onTableChange} pagination={pagination} sorting={sorting} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 5998c62eeacea..e2d9ecccf0626 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -9,7 +9,11 @@ import { Query, Ast } from '@elastic/eui'; import { DATA_FRAME_TASK_STATE } from './data_frame_task_state'; export { DATA_FRAME_TASK_STATE }; -import { DataFrameAnalyticsId, DataFrameAnalyticsConfig } from '../../../../common'; +import { + DataFrameAnalyticsId, + DataFrameAnalyticsConfig, + ANALYSIS_CONFIG_TYPE, +} from '../../../../common'; export enum DATA_FRAME_MODE { BATCH = 'batch', @@ -98,10 +102,15 @@ export function getDataFrameAnalyticsProgressPhase( } export interface DataFrameAnalyticsListRow { - id: DataFrameAnalyticsId; checkpointing: object; config: DataFrameAnalyticsConfig; + id: DataFrameAnalyticsId; + job_type: + | ANALYSIS_CONFIG_TYPE.CLASSIFICATION + | ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION + | ANALYSIS_CONFIG_TYPE.REGRESSION; mode: string; + state: DataFrameAnalyticsStats['state']; stats: DataFrameAnalyticsStats; } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts index 964e8e4062b38..41f3bab8113f0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/get_analytics.ts @@ -10,7 +10,11 @@ import { GetDataFrameAnalyticsStatsResponseError, GetDataFrameAnalyticsStatsResponseOk, } from '../../../../../services/ml_api_service/data_frame_analytics'; -import { REFRESH_ANALYTICS_LIST_STATE, refreshAnalyticsList$ } from '../../../../common'; +import { + ANALYSIS_CONFIG_TYPE, + REFRESH_ANALYTICS_LIST_STATE, + refreshAnalyticsList$, +} from '../../../../common'; import { DATA_FRAME_MODE, @@ -136,10 +140,12 @@ export const getAnalyticsFactory = ( // Table with expandable rows requires `id` on the outer most level reducedtableRows.push({ + checkpointing: {}, config, id: config.id, - checkpointing: {}, + job_type: Object.keys(config.analysis)[0] as ANALYSIS_CONFIG_TYPE, mode: DATA_FRAME_MODE.BATCH, + state: stats.state, stats, }); return reducedtableRows; diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/_analytics_table.scss b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/_analytics_table.scss index 1151599a526e4..f134c467e11b2 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/_analytics_table.scss +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/_analytics_table.scss @@ -19,10 +19,6 @@ } } -.mlAnalyticsProgressBar { - margin-bottom: $euiSizeM; -} - .mlTaskStateBadge { max-width: 100px; } diff --git a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx index 1eeff6287867d..b28729dcf157f 100644 --- a/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/analytics_panel/table.tsx @@ -5,15 +5,15 @@ */ import React, { FC, useState } from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { - mlInMemoryTableFactory, - SortDirection, - SORT_DIRECTION, - OnTableChangeArg, - ColumnType, -} from '../../../components/ml_in_memory_table'; + Direction, + EuiBadge, + EuiInMemoryTable, + EuiTableActionsColumnType, + EuiTableComputedColumnType, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { getAnalysisType } from '../../../data_frame_analytics/common/analytics'; import { DataFrameAnalyticsListColumn, @@ -26,7 +26,14 @@ import { import { getViewAction } from '../../../data_frame_analytics/pages/analytics_management/components/action_view'; import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils'; -const MlInMemoryTable = mlInMemoryTableFactory(); +type DataFrameAnalyticsTableColumns = [ + EuiTableFieldDataColumnType, + EuiTableComputedColumnType, + EuiTableComputedColumnType, + EuiTableComputedColumnType, + EuiTableFieldDataColumnType, + EuiTableActionsColumnType +]; interface Props { items: DataFrameAnalyticsListRow[]; @@ -36,10 +43,10 @@ export const AnalyticsTable: FC = ({ items }) => { const [pageSize, setPageSize] = useState(10); const [sortField, setSortField] = useState(DataFrameAnalyticsListColumn.id); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + const [sortDirection, setSortDirection] = useState('asc'); // id, type, status, progress, created time, view icon - const columns: Array> = [ + const columns: DataFrameAnalyticsTableColumns = [ { field: DataFrameAnalyticsListColumn.id, name: i18n.translate('xpack.ml.overview.analyticsList.id', { defaultMessage: 'ID' }), @@ -87,10 +94,10 @@ export const AnalyticsTable: FC = ({ items }) => { }, ]; - const onTableChange = ({ + const onTableChange: EuiInMemoryTable['onTableChange'] = ({ page = { index: 0, size: 10 }, - sort = { field: DataFrameAnalyticsListColumn.id, direction: SORT_DIRECTION.ASC }, - }: OnTableChangeArg) => { + sort = { field: DataFrameAnalyticsListColumn.id, direction: 'asc' }, + }) => { const { index, size } = page; setPageIndex(index); setPageSize(size); @@ -116,7 +123,7 @@ export const AnalyticsTable: FC = ({ items }) => { }; return ( - (); - // Used to pass on attribute names to table columns export enum AnomalyDetectionListColumns { id = 'id', @@ -46,6 +41,15 @@ export enum AnomalyDetectionListColumns { jobsInGroup = 'jobs_in_group', } +type AnomalyDetectionTableColumns = [ + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableFieldDataColumnType, + EuiTableComputedColumnType +]; + interface Props { items: GroupsDictionary; statsBarData: JobStatsBarStats; @@ -58,10 +62,10 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData const [pageSize, setPageSize] = useState(10); const [sortField, setSortField] = useState(AnomalyDetectionListColumns.id); - const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); + const [sortDirection, setSortDirection] = useState('asc'); // columns: group, max anomaly, jobs in group, latest timestamp, docs processed, action to explorer - const columns: Array> = [ + const columns: AnomalyDetectionTableColumns = [ { field: AnomalyDetectionListColumns.id, name: i18n.translate('xpack.ml.overview.anomalyDetection.tableId', { @@ -169,17 +173,17 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData }, ]; - const onTableChange = ({ + const onTableChange: EuiInMemoryTable['onTableChange'] = ({ page = { index: 0, size: 10 }, - sort = { field: AnomalyDetectionListColumns.id, direction: SORT_DIRECTION.ASC }, - }: OnTableChangeArg) => { + sort = { field: AnomalyDetectionListColumns.id, direction: 'asc' }, + }) => { const { index, size } = page; setPageIndex(index); setPageSize(size); const { field, direction } = sort; - setSortField(field); - setSortDirection(direction); + setSortField(field as string); + setSortDirection(direction as Direction); }; const pagination = { @@ -214,7 +218,7 @@ export const AnomalyDetectionTable: FC = ({ items, jobsList, statsBarData - Date: Thu, 16 Jul 2020 13:18:01 +0100 Subject: [PATCH 52/52] [Observability] Update empty state links (#72025) * changing links * changing title --- .../public/pages/overview/empty_section.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/overview/empty_section.ts b/x-pack/plugins/observability/public/pages/overview/empty_section.ts index e30eda9f3e056..0330ba5cc04b4 100644 --- a/x-pack/plugins/observability/public/pages/overview/empty_section.ts +++ b/x-pack/plugins/observability/public/pages/overview/empty_section.ts @@ -22,7 +22,7 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I linkTitle: i18n.translate('xpack.observability.emptySection.apps.logs.link', { defaultMessage: 'Install Filebeat', }), - href: 'https://www.elastic.co', + href: core.http.basePath.prepend('/app/home#/tutorial_directory/logging'), }, { id: 'apm', @@ -35,9 +35,9 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I 'Trace transactions through a distributed architecture and map your services’ interactions to easily spot performance bottlenecks.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.apm.link', { - defaultMessage: 'Install agent', + defaultMessage: 'Install Agent', }), - href: 'https://www.elastic.co', + href: core.http.basePath.prepend('/app/home#/tutorial/apm'), }, { id: 'infra_metrics', @@ -50,9 +50,9 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I 'Analyze metrics from your infrastructure, apps, and services. Discover trends, forecast behavior, get alerts on anomalies, and more.', }), linkTitle: i18n.translate('xpack.observability.emptySection.apps.metrics.link', { - defaultMessage: 'Install metrics module', + defaultMessage: 'Install Metricbeat', }), - href: 'https://www.elastic.co', + href: core.http.basePath.prepend('/app/home#/tutorial_directory/metrics'), }, { id: 'uptime', @@ -67,7 +67,7 @@ export const getEmptySections = ({ core }: { core: AppMountContext['core'] }): I linkTitle: i18n.translate('xpack.observability.emptySection.apps.uptime.link', { defaultMessage: 'Install Heartbeat', }), - href: 'https://www.elastic.co', + href: core.http.basePath.prepend('/app/home#/tutorial/uptimeMonitors'), }, { id: 'alert',