diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index 2c6fee861ce7d..048efa5008533 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -85,7 +85,7 @@ describe('checking migration metadata changes on all registered SO types', () => "endpoint:user-artifact": "f94c250a52b30d0a2d32635f8b4c5bdabd1e25c0", "endpoint:user-artifact-manifest": "8c14d49a385d5d1307d956aa743ec78de0b2be88", "enterprise_search_telemetry": "fafcc8318528d34f721c42d1270787c52565bad5", - "epm-packages": "2915aee4302d4b00472ed05c21f59b7d498b5206", + "epm-packages": "7d80ba3f1fcd80316aa0b112657272034b66d5a8", "epm-packages-assets": "9fd3d6726ac77369249e9a973902c2cd615fc771", "event_loop_delays_daily": "d2ed39cf669577d90921c176499908b4943fb7bd", "exception-list": "fe8cc004fd2742177cdb9300f4a67689463faf9c", diff --git a/src/core/server/integration_tests/saved_objects/migrations/multiple_kibana_nodes.test.ts b/src/core/server/integration_tests/saved_objects/migrations/multiple_kibana_nodes.test.ts index a947854e9249b..34df1d484b92b 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/multiple_kibana_nodes.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/multiple_kibana_nodes.test.ts @@ -106,7 +106,8 @@ async function createRoot({ logFileName }: CreateRootConfig) { // suite is very long, the 10mins default can cause timeouts jest.setTimeout(15 * 60 * 1000); -describe('migration v2', () => { +// FLAKY: https://github.com/elastic/kibana/issues/148263 +describe.skip('migration v2', () => { let esServer: TestElasticsearchUtils; let rootA: Root; let rootB: Root; diff --git a/src/plugins/discover/public/application/main/hooks/use_data_state.ts b/src/plugins/discover/public/application/main/hooks/use_data_state.ts index fe512e747b4a1..e27e31f147671 100644 --- a/src/plugins/discover/public/application/main/hooks/use_data_state.ts +++ b/src/plugins/discover/public/application/main/hooks/use_data_state.ts @@ -15,7 +15,7 @@ export function useDataState(data$: BehaviorSubject) { useEffect(() => { const subscription = data$.subscribe((next) => { if (next.fetchStatus !== fetchState.fetchStatus) { - setFetchState({ ...fetchState, ...next }); + setFetchState({ ...fetchState, ...next, ...(next.error ? {} : { error: undefined }) }); } }); return () => subscription.unsubscribe(); diff --git a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/editor_footer.tsx b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/editor_footer.tsx index c9baf17eeec8d..ff7237e059925 100644 --- a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/editor_footer.tsx +++ b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/editor_footer.tsx @@ -21,6 +21,9 @@ import { EuiDescriptionListDescription, } from '@elastic/eui'; import { Interpolation, Theme, css } from '@emotion/react'; +import { css as classNameCss } from '@emotion/css'; + +import type { MonacoError } from './helpers'; const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const COMMAND_KEY = isMac ? '⌘' : '^'; @@ -28,13 +31,17 @@ const COMMAND_KEY = isMac ? '⌘' : '^'; interface EditorFooterProps { lines: number; containerCSS: Interpolation; - errors?: Array<{ startLineNumber: number; message: string }>; + errors?: MonacoError[]; + onErrorClick: (error: MonacoError) => void; + refreshErrors: () => void; } export const EditorFooter = memo(function EditorFooter({ lines, containerCSS, errors, + onErrorClick, + refreshErrors, }: EditorFooterProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); return ( @@ -75,7 +82,10 @@ export const EditorFooter = memo(function EditorFooter({ text-decoration: underline; } `} - onClick={() => setIsPopoverOpen(!isPopoverOpen)} + onClick={() => { + refreshErrors(); + setIsPopoverOpen(!isPopoverOpen); + }} >

{i18n.translate( @@ -104,7 +114,15 @@ export const EditorFooter = memo(function EditorFooter({ {errors.map((error, index) => { return ( - + onErrorClick(error)} + > diff --git a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/helpers.ts b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/helpers.ts index 191b82f5817a3..58e603fa62d4f 100644 --- a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/helpers.ts +++ b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/helpers.ts @@ -11,6 +11,15 @@ import useDebounce from 'react-use/lib/useDebounce'; import { monaco } from '@kbn/monaco'; import { i18n } from '@kbn/i18n'; +export interface MonacoError { + message: string; + startColumn: number; + startLineNumber: number; + endColumn: number; + endLineNumber: number; + severity: monaco.MarkerSeverity; +} + export const useDebounceWithOptions = ( fn: Function, { skipFirstRender }: { skipFirstRender: boolean } = { skipFirstRender: false }, @@ -33,7 +42,7 @@ export const useDebounceWithOptions = ( ); }; -export const parseErrors = (errors: Error[], code: string) => { +export const parseErrors = (errors: Error[], code: string): MonacoError[] => { return errors.map((error) => { if (error.message.includes('line')) { const text = error.message.split('line')[1]; diff --git a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/index.tsx b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/index.tsx index 34e1eab4bd55f..18f08bfa750eb 100644 --- a/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/index.tsx +++ b/src/plugins/unified_search/public/query_string_input/text_based_languages_editor/index.tsx @@ -43,6 +43,7 @@ import { parseErrors, getInlineEditorText, getDocumentationSections, + MonacoError, } from './helpers'; import { EditorFooter } from './editor_footer'; import { ResizableButton } from './resizable_button'; @@ -103,9 +104,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ const [isCompactFocused, setIsCompactFocused] = useState(isCodeEditorExpanded); const [isCodeEditorExpandedFocused, setIsCodeEditorExpandedFocused] = useState(false); const [isWordWrapped, setIsWordWrapped] = useState(true); - const [editorErrors, setEditorErrors] = useState< - Array<{ startLineNumber: number; message: string }> - >([]); + const [editorErrors, setEditorErrors] = useState([]); const [documentationSections, setDocumentationSections] = useState(); const kibana = useKibana(); @@ -241,6 +240,19 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ [errors] ); + const onErrorClick = useCallback(({ startLineNumber, startColumn }: MonacoError) => { + if (!editor1.current) { + return; + } + + editor1.current.focus(); + editor1.current.setPosition({ + lineNumber: startLineNumber, + column: startColumn, + }); + editor1.current.revealLine(startLineNumber); + }, []); + // Clean up the monaco editor and DOM on unmount useEffect(() => { const model = editorModel; @@ -456,7 +468,12 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ )} - + {(resizeRef) => ( )} @@ -577,7 +596,13 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({ )} {isCodeEditorExpanded && ( - + )} {isCodeEditorExpanded && ( { const timeRange = useTimeRangeUpdates(); - useMount(function updateIntervalOnTimeBoundsChange() { + useEffect(function updateIntervalOnTimeBoundsChange() { const timeUpdateSubscription = timefilter .getTimeUpdate$() .pipe(startWith(timefilter.getTime())) @@ -145,7 +144,8 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { return () => { timeUpdateSubscription.unsubscribe(); }; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const metricFieldOptions = useMemo(() => { return dataView.fields.filter(({ aggregatable, type }) => aggregatable && type === 'number'); @@ -195,7 +195,7 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { [filterManager] ); - useMount(() => { + useEffect(() => { setResultFilter(filterManager.getFilters()); const sub = filterManager.getUpdates$().subscribe(() => { setResultFilter(filterManager.getFilters()); @@ -203,7 +203,8 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { return () => { sub.unsubscribe(); }; - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect( function syncFilters() { diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index dc8ccf55c9f5d..bc385b68aeeaf 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -127,8 +127,11 @@ export type SingleCaseMetricsFeature = | 'lifespan'; export enum SortFieldCase { - createdAt = 'createdAt', closedAt = 'closedAt', + createdAt = 'createdAt', + severity = 'severity', + status = 'status', + title = 'title', } export type ElasticUser = SnakeToCamelCase; diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index 838ebeea59030..3450313114a0b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { mount } from 'enzyme'; import moment from 'moment-timezone'; -import { render, waitFor, screen, act } from '@testing-library/react'; +import { render, waitFor, screen, act, within } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; @@ -257,23 +257,28 @@ describe('AllCasesListGeneric', () => { expect.objectContaining({ queryParams: { ...DEFAULT_QUERY_PARAMS, - sortOrder: 'asc', }, }) ); }); }); + it('renders the title column', async () => { + const res = appMockRenderer.render(); + + expect(res.getByTestId('tableHeaderCell_title_0')).toBeInTheDocument(); + }); + it('renders the status column', async () => { const res = appMockRenderer.render(); - expect(res.getByTestId('tableHeaderCell_Status_6')).toBeInTheDocument(); + expect(res.getByTestId('tableHeaderCell_status_6')).toBeInTheDocument(); }); it('renders the severity column', async () => { const res = appMockRenderer.render(); - expect(res.getByTestId('tableHeaderCell_Severity_7')).toBeInTheDocument(); + expect(res.getByTestId('tableHeaderCell_severity_7')).toBeInTheDocument(); }); it('should render the case stats', () => { @@ -393,6 +398,66 @@ describe('AllCasesListGeneric', () => { }); }); + it('should sort by status', async () => { + const result = appMockRenderer.render(); + + userEvent.click( + within(result.getByTestId('tableHeaderCell_status_6')).getByTestId('tableHeaderSortButton') + ); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + ...DEFAULT_QUERY_PARAMS, + sortField: SortFieldCase.status, + sortOrder: 'asc', + }, + }) + ); + }); + }); + + it('should sort by severity', async () => { + const result = appMockRenderer.render(); + + userEvent.click( + within(result.getByTestId('tableHeaderCell_severity_7')).getByTestId('tableHeaderSortButton') + ); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + ...DEFAULT_QUERY_PARAMS, + sortField: SortFieldCase.severity, + sortOrder: 'asc', + }, + }) + ); + }); + }); + + it('should sort by title', async () => { + const result = appMockRenderer.render(); + + userEvent.click( + within(result.getByTestId('tableHeaderCell_title_0')).getByTestId('tableHeaderSortButton') + ); + + await waitFor(() => { + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + ...DEFAULT_QUERY_PARAMS, + sortField: SortFieldCase.title, + sortOrder: 'asc', + }, + }) + ); + }); + }); + it('should filter by status: closed', async () => { const result = appMockRenderer.render(); userEvent.click(result.getByTestId('case-status-filter')); diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index d4ba5692a0b72..3d786b787369a 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -45,7 +45,8 @@ const ProgressLoader = styled(EuiProgress)` `; const getSortField = (field: string): SortFieldCase => - field === SortFieldCase.closedAt ? SortFieldCase.closedAt : SortFieldCase.createdAt; + // @ts-ignore + SortFieldCase[field] ?? SortFieldCase.title; export interface AllCasesListProps { hiddenStatuses?: CaseStatusWithAllStatus[]; diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx index 0647bdd25e382..0b8777465ff2d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -49,8 +49,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -95,12 +97,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -130,8 +136,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -176,12 +184,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -210,8 +222,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -250,12 +264,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -282,8 +300,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -321,12 +341,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -347,8 +371,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -387,12 +413,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -418,8 +448,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -458,12 +490,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -487,8 +523,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -527,12 +565,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -555,8 +597,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -595,12 +639,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, Object { "align": "right", @@ -622,8 +670,10 @@ describe('useCasesColumns ', () => { Object { "columns": Array [ Object { + "field": "title", "name": "Name", "render": [Function], + "sortable": true, "width": "20%", }, Object { @@ -662,12 +712,16 @@ describe('useCasesColumns ', () => { "render": [Function], }, Object { + "field": "status", "name": "Status", "render": [Function], + "sortable": true, }, Object { + "field": "severity", "name": "Severity", "render": [Function], + "sortable": true, }, ], } diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx index df261b613b0b2..601e60ae9a5c5 100644 --- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx @@ -112,8 +112,10 @@ export const useCasesColumns = ({ const columns: CasesColumns[] = [ { + field: 'title', name: i18n.NAME, - render: (theCase: Case) => { + sortable: true, + render: (title: string, theCase: Case) => { if (theCase.id != null && theCase.title != null) { const caseDetailsLinkComponent = isSelectorView ? ( @@ -287,23 +289,27 @@ export const useCasesColumns = ({ }, }, { + field: 'status', name: i18n.STATUS, - render: (theCase: Case) => { - if (theCase.status === null || theCase.status === undefined) { - return getEmptyTagValue(); + sortable: true, + render: (status: Case['status']) => { + if (status != null) { + return ; } - return ; + return getEmptyTagValue(); }, }, { + field: 'severity', name: i18n.SEVERITY, - render: (theCase: Case) => { - if (theCase.severity != null) { - const severityData = severities[theCase.severity ?? CaseSeverity.LOW]; + sortable: true, + render: (severity: Case['severity']) => { + if (severity != null) { + const severityData = severities[severity ?? CaseSeverity.LOW]; return ( {severityData.label} diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 644c6bb5a8c42..797b29ad2be32 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -59,8 +59,3 @@ export const SUPPORTED_CLOUDBEAT_INPUTS = [ CLOUDBEAT_GCP, CLOUDBEAT_AZURE, ] as const; - -export type CLOUDBEAT_INTEGRATION = typeof SUPPORTED_CLOUDBEAT_INPUTS[number]; -export type POLICY_TEMPLATE = typeof SUPPORTED_POLICY_TEMPLATES[number]; -export type PostureInput = typeof SUPPORTED_CLOUDBEAT_INPUTS[number]; -export type PosturePolicyTemplate = typeof SUPPORTED_POLICY_TEMPLATES[number]; diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index 29525fa95e19b..757ec5ebb0eb5 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -6,6 +6,7 @@ */ import type { PackagePolicy, AgentPolicy } from '@kbn/fleet-plugin/common'; +import { SUPPORTED_CLOUDBEAT_INPUTS, SUPPORTED_POLICY_TEMPLATES } from './constants'; import type { CspRuleMetadata } from './schemas/csp_rule_metadata'; export type Evaluation = 'passed' | 'failed' | 'NA'; @@ -100,3 +101,7 @@ export interface Benchmark { export type BenchmarkId = CspRuleMetadata['benchmark']['id']; export type BenchmarkName = CspRuleMetadata['benchmark']['name']; + +// Fleet Integration types +export type PostureInput = typeof SUPPORTED_CLOUDBEAT_INPUTS[number]; +export type PosturePolicyTemplate = typeof SUPPORTED_POLICY_TEMPLATES[number]; diff --git a/x-pack/plugins/cloud_security_posture/public/common/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/constants.ts index 1a96b8b095541..521885044f480 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/constants.ts @@ -7,14 +7,13 @@ import { i18n } from '@kbn/i18n'; import { euiThemeVars } from '@kbn/ui-theme'; +import type { PosturePolicyTemplate, PostureInput } from '../../common/types'; import { CLOUDBEAT_EKS, CLOUDBEAT_VANILLA, CLOUDBEAT_AWS, CLOUDBEAT_GCP, CLOUDBEAT_AZURE, - type PostureInput, - type PosturePolicyTemplate, } from '../../common/constants'; import eksLogo from '../assets/icons/cis_eks_logo.svg'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/utils/get_enabled_csp_integration_details.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/get_enabled_csp_integration_details.ts index eff420fd94af4..4cf3d7d129f0c 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/utils/get_enabled_csp_integration_details.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/utils/get_enabled_csp_integration_details.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { PackagePolicy } from '@kbn/fleet-plugin/common'; -import { PostureInput, SUPPORTED_CLOUDBEAT_INPUTS } from '../../../common/constants'; -import { cloudPostureIntegrations, CloudPostureIntegrations } from '../constants'; +import type { PackagePolicy } from '@kbn/fleet-plugin/common'; +import type { PostureInput } from '../../../common/types'; +import { SUPPORTED_CLOUDBEAT_INPUTS } from '../../../common/constants'; +import { cloudPostureIntegrations, type CloudPostureIntegrations } from '../constants'; const isPolicyTemplate = (name: unknown): name is keyof CloudPostureIntegrations => typeof name === 'string' && name in cloudPostureIntegrations; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form.tsx index e3cae37d18600..3585e63cf5143 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/aws_credentials_form.tsx @@ -17,7 +17,7 @@ import type { NewPackagePolicy } from '@kbn/fleet-plugin/public'; import { NewPackagePolicyInput } from '@kbn/fleet-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { InlineRadioGroup } from './inline_radio_group'; +import { RadioGroup } from './csp_boxed_radio_group'; import { getPosturePolicy, NewPackagePolicyPostureInput } from './utils'; const DocsLink = ( @@ -197,11 +197,13 @@ const getInputVarsFields = ( } as const; }); -const getDefaultAwsType = (input: Props['input']): AwsCredentialsType => - input.streams[0].vars['aws.credentials.type'].value; +const getAwsCredentialsType = (input: Props['input']): AwsCredentialsType | undefined => + input.streams[0].vars?.['aws.credentials.type'].value; export const AwsCredentialsForm = ({ input, newPolicy, updatePolicy }: Props) => { - const awsCredentialsType = getDefaultAwsType(input); + // We only have a value for 'aws.credentials.type' once the form has mounted. + // On initial render we don't have that value so we default to the first option. + const awsCredentialsType = getAwsCredentialsType(input) || AWS_CREDENTIALS_OPTIONS[0].id; const group = options[awsCredentialsType]; const fields = getInputVarsFields(input, group.fields); @@ -240,7 +242,7 @@ const AwsCredentialTypeSelector = ({ onChange(type: AwsCredentialsType): void; type: AwsCredentialsType; }) => ( - { + const { euiTheme } = useEuiTheme(); + + return ( +

+ {options.map((option) => { + const isChecked = option.id === idSelected; + return ( + + onChange(option.id)} + iconType={option.icon} + iconSide="right" + contentProps={{ + style: { + justifyContent: 'flex-start', + }, + }} + css={css` + width: 100%; + height: ${size === 's' ? euiTheme.size.xxl : euiTheme.size.xxxl}; + svg, + img { + margin-left: auto; + } + + &&, + &&:hover { + text-decoration: none; + } + &:disabled { + svg, + img { + filter: grayscale(1); + } + } + `} + > + {}} + /> + + + ); + })} +
+ ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/deployment_type_select.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/deployment_type_select.tsx deleted file mode 100644 index bc5c61fa0370b..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/deployment_type_select.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiComboBox, - EuiToolTip, - EuiFormRow, - EuiIcon, - type EuiComboBoxOptionOption, - EuiDescribedFormGroup, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { cloudPostureIntegrations } from '../../common/constants'; -import { CLOUDBEAT_INTEGRATION, POLICY_TEMPLATE } from '../../../common/constants'; - -interface Props { - policyTemplate: POLICY_TEMPLATE; - type: CLOUDBEAT_INTEGRATION; - onChange?: (type: CLOUDBEAT_INTEGRATION) => void; - isDisabled?: boolean; -} - -const kubeDeployOptions = ( - policyTemplate: POLICY_TEMPLATE -): Array> => - cloudPostureIntegrations[policyTemplate].options.map((o) => ({ value: o.type, label: o.name })); - -const KubernetesDeploymentFieldLabel = () => ( - - } - > - - - -   - - - - -); - -export const DeploymentTypeSelect = ({ policyTemplate, type, isDisabled, onChange }: Props) => ( - }> - }> - o.value === type)} - isDisabled={isDisabled} - onChange={(options) => !isDisabled && onChange?.(options[0].value!)} - /> - - -); diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/eks_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/eks_form.tsx deleted file mode 100644 index b160561807d64..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/eks_form.tsx +++ /dev/null @@ -1,153 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { - EuiFormRow, - EuiFieldText, - EuiDescribedFormGroup, - EuiText, - EuiSpacer, - EuiLink, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { NewPackagePolicyInput } from '@kbn/fleet-plugin/common'; -import { i18n } from '@kbn/i18n'; -import { isEksInput } from './utils'; - -export const eksVars = [ - { - id: 'access_key_id', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.accessKeyIdFieldLabel', - { defaultMessage: 'Access key ID' } - ), - }, - { - id: 'secret_access_key', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.secretAccessKeyFieldLabel', - { defaultMessage: 'Secret Access Key' } - ), - }, - { - id: 'session_token', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sessionTokenFieldLabel', - { defaultMessage: 'Session Token' } - ), - }, - { - id: 'shared_credential_file', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialsFileFieldLabel', - { defaultMessage: 'Shared Credential File' } - ), - }, - { - id: 'credential_profile_name', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialFileFieldLabel', - { defaultMessage: 'Credential Profile Name' } - ), - }, - { - id: 'role_arn', - label: i18n.translate( - 'xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.roleARNFieldLabel', - { defaultMessage: 'ARN Role' } - ), - }, -] as const; - -type EksVars = typeof eksVars; -type EksVarId = EksVars[number]['id']; -type EksFormVars = { [K in EksVarId]: string }; - -interface Props { - onChange(key: EksVarId, value: string): void; - inputs: NewPackagePolicyInput[]; -} - -const getEksVars = (input?: NewPackagePolicyInput): EksFormVars => { - const vars = input?.streams?.[0]?.vars; - return { - access_key_id: vars?.access_key_id.value || '', - secret_access_key: vars?.secret_access_key.value || '', - session_token: vars?.session_token.value || '', - shared_credential_file: vars?.shared_credential_file.value || '', - credential_profile_name: vars?.credential_profile_name.value || '', - role_arn: vars?.role_arn.value || '', - }; -}; - -export const EksFormWrapper = ({ onChange, inputs }: Props) => ( - <> - - - -); - -const EksForm = ({ onChange, inputs }: Props) => { - const values = getEksVars(inputs.find(isEksInput)); - - const eksFormTitle = ( -

- -

- ); - - const eksFormDescription = ( - <> - - - - ), - }} - /> - - - - ); - - return ( - - {eksVars.map((field) => ( - - - - } - > - onChange(field.id, event.target.value)} - /> - - ))} - - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/inline_radio_group.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/inline_radio_group.tsx deleted file mode 100644 index 6aba7275a79dd..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/inline_radio_group.tsx +++ /dev/null @@ -1,83 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useEuiTheme, EuiRadioGroup, type EuiRadioGroupProps } from '@elastic/eui'; -import { css } from '@emotion/react'; - -type RadioGroupProps = Pick; - -type Props = RadioGroupProps & { - size?: 's' | 'm'; -}; - -export const InlineRadioGroup = ({ idSelected, size, options, disabled, onChange }: Props) => { - const { euiTheme } = useEuiTheme(); - - return ( - ({ - id: o.id, - label: o.label, - disabled: o.disabled, - ['data-enabled']: idSelected === o.id, - ['data-disabled']: o.disabled, - className: '__extendedRadioOption', - }))} - onChange={onChange} - css={css` - display: grid; - grid-template-columns: repeat(${options.length}, 1fr); - grid-template-rows: ${size === 's' ? euiTheme.size.xxl : euiTheme.size.xxxl}; - column-gap: ${euiTheme.size.s}; - align-items: center; - - > .__extendedRadioOption { - margin-top: 0; - height: 100%; - padding-left: ${euiTheme.size.m}; - padding-right: ${euiTheme.size.m}; - - display: grid; - grid-template-columns: auto 1fr; - column-gap: ${euiTheme.size.s}; - align-items: center; - - border: 1px solid ${euiTheme.colors.lightShade}; - border-radius: ${euiTheme.border.radius.medium}; - background: ${euiTheme.colors.emptyShade}; - - &[data-enabled='true'] { - border-color: ${euiTheme.colors.primary}; - background: ${euiTheme.colors.lightestShade}; - } - - &[data-disabled='true'] { - border-color: ${euiTheme.colors.disabled}; - background: ${euiTheme.colors.emptyShade}; - } - - // EuiRadio shows an absolute positioned div as a circle instead of input[type=radio] which is hidden - // removing the absolute position to make it part of document flow, set by css grid - &.__extendedRadioOption { - & > *:not(label):not(input) { - position: inherit; - top: 0; - left: 0; - } - - & > label { - padding-left: 0; - } - } - } - `} - /> - ); -}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/mocks.ts b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/mocks.ts index bf8616aaeeb3b..ecc435aa5961d 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/mocks.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/mocks.ts @@ -9,7 +9,7 @@ import type { PackagePolicy } from '@kbn/fleet-plugin/common'; import { createNewPackagePolicyMock } from '@kbn/fleet-plugin/common/mocks'; import { BenchmarkId } from '../../../common/types'; import { CLOUDBEAT_EKS, CLOUDBEAT_VANILLA } from '../../../common/constants'; -import { PostureInput } from '../../../common/constants'; +import type { PostureInput } from '../../../common/types'; export const getCspNewPolicyMock = (type: BenchmarkId = 'cis_k8s'): NewPackagePolicy => ({ name: 'some-cloud_security_posture-policy', diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.test.tsx deleted file mode 100644 index 29916477837f1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.test.tsx +++ /dev/null @@ -1,93 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import CspCreatePolicyExtension from './policy_extension_create'; -import { eksVars } from './eks_form'; -import Chance from 'chance'; -import { TestProvider } from '../../test/test_provider'; -import userEvent from '@testing-library/user-event'; -import { getCspNewPolicyMock } from './mocks'; - -// ensures that fields appropriately match to their label -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - ...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'), - htmlIdGenerator: () => () => `id-${Math.random()}`, -})); - -// ensures that fields appropriately match to their label -jest.mock('@elastic/eui/lib/services/accessibility', () => ({ - ...jest.requireActual('@elastic/eui/lib/services/accessibility'), - useGeneratedHtmlId: () => `id-${Math.random()}`, -})); - -const chance = new Chance(); - -describe('', () => { - const onChange = jest.fn(); - - const WrappedComponent = ({ newPolicy = getCspNewPolicyMock() }) => ( - - - - ); - - beforeEach(() => { - onChange.mockClear(); - }); - - it('renders non-disabled ', () => { - const { getByLabelText } = render(); - const input = getByLabelText('Kubernetes Deployment') as HTMLInputElement; - expect(input).toBeInTheDocument(); - expect(input).not.toBeDisabled(); - }); - - it('renders non-disabled ', () => { - const { getByLabelText } = render( - - ); - - eksVars.forEach((eksVar) => { - expect(getByLabelText(eksVar.label)).toBeInTheDocument(); - expect(getByLabelText(eksVar.label)).not.toBeDisabled(); - }); - }); - - it('handles updating deployment type', () => { - const { getByLabelText } = render(); - const input = getByLabelText('Kubernetes Deployment') as HTMLInputElement; - - userEvent.type(input, 'EKS (Elastic Kubernetes Service){enter}'); - - expect(onChange).toBeCalledWith({ - isValid: true, - updatedPolicy: getCspNewPolicyMock('cis_eks'), - }); - }); - - it('handles updating EKS vars', () => { - const { getByLabelText } = render( - - ); - - const randomValues = chance.unique(chance.string, eksVars.length); - - eksVars.forEach((eksVar, i) => { - const eksVarInput = getByLabelText(eksVar.label) as HTMLInputElement; - fireEvent.change(eksVarInput, { target: { value: randomValues[i] } }); - - const policy = getCspNewPolicyMock('cis_eks'); - policy.inputs[1].streams[0].vars![eksVar.id].value = randomValues[i]; - - expect(onChange).toBeCalledWith({ - isValid: true, - updatedPolicy: policy, - }); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.tsx index 790cd8978725c..b4d3828dd97b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_create.tsx @@ -5,47 +5,13 @@ * 2.0. */ import React, { memo } from 'react'; -import { EuiForm } from '@elastic/eui'; import type { PackagePolicyCreateExtensionComponentProps } from '@kbn/fleet-plugin/public'; -import { CLOUDBEAT_AWS, CLOUDBEAT_EKS, CLOUDBEAT_INTEGRATION } from '../../../common/constants'; -import { DeploymentTypeSelect } from './deployment_type_select'; -import { EksFormWrapper } from './eks_form'; -import { - getEnabledInput, - getEnabledInputType, - getUpdatedDeploymentType, - getUpdatedEksVar, -} from './utils'; +import { CspPolicyTemplateForm } from './policy_template_form'; export const CspCreatePolicyExtension = memo( - ({ newPolicy, onChange }) => { - const selectedDeploymentType = getEnabledInputType(newPolicy.inputs); - const selectedInput = getEnabledInput(newPolicy.inputs); - const policyTemplate = selectedInput?.policy_template; - const updateDeploymentType = (inputType: CLOUDBEAT_INTEGRATION) => - onChange(getUpdatedDeploymentType(newPolicy, inputType)); - - const updateEksVar = (key: string, value: string) => - onChange(getUpdatedEksVar(newPolicy, key, value)); - - return ( - - {selectedInput && (policyTemplate === 'kspm' || policyTemplate === 'cspm') && ( - <> - - {(selectedDeploymentType === CLOUDBEAT_EKS || - selectedDeploymentType === CLOUDBEAT_AWS) && ( - - )} - - )} - - ); - } + ({ newPolicy, onChange }) => ( + + ) ); CspCreatePolicyExtension.displayName = 'CspCreatePolicyExtension'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.test.tsx deleted file mode 100644 index 856e4650ff045..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.test.tsx +++ /dev/null @@ -1,80 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import CspEditPolicyExtension from './policy_extension_edit'; -import { TestProvider } from '../../test/test_provider'; -import { getCspNewPolicyMock, getCspPolicyMock } from './mocks'; -import Chance from 'chance'; -import { eksVars } from './eks_form'; - -const chance = new Chance(); - -// ensures that fields appropriately match to their label -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - ...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'), - htmlIdGenerator: () => () => `id-${Math.random()}`, -})); - -// ensures that fields appropriately match to their label -jest.mock('@elastic/eui/lib/services/accessibility', () => ({ - ...jest.requireActual('@elastic/eui/lib/services/accessibility'), - useGeneratedHtmlId: () => `id-${Math.random()}`, -})); - -describe('', () => { - const onChange = jest.fn(); - - const WrappedComponent = ({ policy = getCspPolicyMock(), newPolicy = getCspNewPolicyMock() }) => ( - - - - ); - - beforeEach(() => { - onChange.mockClear(); - }); - - it('renders disabled ', () => { - const { getByLabelText } = render(); - const input = getByLabelText('Kubernetes Deployment') as HTMLInputElement; - expect(input).toBeInTheDocument(); - expect(input).toBeDisabled(); - }); - - it('renders non-disabled ', () => { - const { getByLabelText } = render( - - ); - - eksVars.forEach((eksVar) => { - expect(getByLabelText(eksVar.label)).toBeInTheDocument(); - expect(getByLabelText(eksVar.label)).not.toBeDisabled(); - }); - }); - - it('handles updating EKS vars', () => { - const { getByLabelText } = render( - - ); - - const randomValues = chance.unique(chance.string, eksVars.length); - - eksVars.forEach((eksVar, i) => { - const eksVarInput = getByLabelText(eksVar.label) as HTMLInputElement; - fireEvent.change(eksVarInput, { target: { value: randomValues[i] } }); - - const policy = getCspNewPolicyMock('cis_eks'); - policy.inputs[1].streams[0].vars![eksVar.id].value = randomValues[i]; - - expect(onChange).toBeCalledWith({ - isValid: true, - updatedPolicy: policy, - }); - }); - }); -}); diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.tsx index e268dac8cd14e..b2be1b0c0d7cc 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_extension_edit.tsx @@ -5,40 +5,13 @@ * 2.0. */ import React, { memo } from 'react'; -import { EuiForm } from '@elastic/eui'; import type { PackagePolicyEditExtensionComponentProps } from '@kbn/fleet-plugin/public'; -import { CLOUDBEAT_EKS, CLOUDBEAT_AWS } from '../../../common/constants'; -import { DeploymentTypeSelect } from './deployment_type_select'; -import { EksFormWrapper } from './eks_form'; -import { getEnabledInput, getEnabledInputType, getUpdatedEksVar } from './utils'; +import { CspPolicyTemplateForm } from './policy_template_form'; export const CspEditPolicyExtension = memo( - ({ newPolicy, onChange }) => { - const selectedDeploymentType = getEnabledInputType(newPolicy.inputs); - const selectedInput = getEnabledInput(newPolicy.inputs); - const policyTemplate = selectedInput?.policy_template; - - const updateEksVar = (key: string, value: string) => - onChange(getUpdatedEksVar(newPolicy, key, value)); - - return ( - - {(policyTemplate === 'kspm' || policyTemplate === 'cspm') && ( - <> - - {(selectedDeploymentType === CLOUDBEAT_EKS || - selectedDeploymentType === CLOUDBEAT_AWS) && ( - - )} - - )} - - ); - } + ({ newPolicy, onChange }) => ( + + ) ); CspEditPolicyExtension.displayName = 'CspEditPolicyExtension'; diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx index a43a9fd26fb10..0d4ab59bd31b9 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.test.tsx @@ -12,6 +12,7 @@ import { getMockPolicyAWS, getMockPolicyEKS, getMockPolicyK8s } from './mocks'; import type { NewPackagePolicy } from '@kbn/fleet-plugin/common'; import userEvent from '@testing-library/user-event'; import { getPosturePolicy } from './utils'; +import { CLOUDBEAT_AWS, CLOUDBEAT_EKS } from '../../../common/constants'; describe('', () => { const onChange = jest.fn(); @@ -148,8 +149,8 @@ describe('', () => { * AWS Credentials input fields tests for KSPM/CSPM integrations */ const awsInputs = { - 'cloudbeat/cis_eks': getMockPolicyEKS, - 'cloudbeat/cis_aws': getMockPolicyAWS, + [CLOUDBEAT_EKS]: getMockPolicyEKS, + [CLOUDBEAT_AWS]: getMockPolicyAWS, }; for (const [inputKey, getPolicy] of Object.entries(awsInputs) as Array< diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx index 3298d368bf7d9..eee8f86ef6b48 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_form.tsx @@ -10,7 +10,8 @@ import type { NewPackagePolicy, PackagePolicyCreateExtensionComponentProps, } from '@kbn/fleet-plugin/public'; -import { CLOUDBEAT_AWS, CLOUDBEAT_VANILLA, PostureInput } from '../../../common/constants'; +import type { PostureInput } from '../../../common/types'; +import { CLOUDBEAT_AWS, CLOUDBEAT_VANILLA } from '../../../common/constants'; import { getPosturePolicy, INPUTS_WITH_AWS_VARS, diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_input_selector.tsx b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_input_selector.tsx index be3ba857e9691..34d00a95d6899 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_input_selector.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/policy_template_input_selector.tsx @@ -5,20 +5,11 @@ * 2.0. */ import React from 'react'; -import { - EuiFlexGroup, - EuiToolTip, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { css } from '@emotion/react'; -import type { PostureInput } from '../../../common/constants'; +import type { PostureInput, PosturePolicyTemplate } from '../../../common/types'; import { getPolicyTemplateInputOptions, type NewPackagePolicyPostureInput } from './utils'; -import { InlineRadioGroup } from './inline_radio_group'; +import { RadioGroup } from './csp_boxed_radio_group'; interface Props { disabled: boolean; @@ -26,43 +17,19 @@ interface Props { setInput: (inputType: PostureInput) => void; } -const RadioLabel = ({ - label, - icon, - disabled, - tooltip, -}: ReturnType[number]) => ( - - - {label} - {icon && ( - - - - )} - - -); - export const PolicyInputSelector = ({ input, disabled, setInput }: Props) => { const baseOptions = getPolicyTemplateInputOptions(input.policy_template); const options = baseOptions.map((option) => ({ ...option, disabled: option.disabled || disabled, - label: , + label: option.label, + icon: option.icon, })); return (
- + ( ); + +const ConfigureIntegrationInfo = ({ type }: { type: PosturePolicyTemplate }) => ( + <> + +

+ +

+
+ + + {type === 'kspm' && ( + + )} + {type === 'cspm' && ( + + )} + + + +); diff --git a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/utils.ts b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/utils.ts index e6fe0e09e44f6..dde25b7477543 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/fleet_extensions/utils.ts @@ -7,7 +7,6 @@ import type { NewPackagePolicy, NewPackagePolicyInput, - NewPackagePolicyInputStream, PackagePolicyConfigRecordEntry, } from '@kbn/fleet-plugin/common'; import merge from 'lodash/merge'; @@ -15,96 +14,26 @@ import { CLOUDBEAT_AWS, CLOUDBEAT_EKS, CLOUDBEAT_VANILLA, + CLOUDBEAT_GCP, + CLOUDBEAT_AZURE, SUPPORTED_POLICY_TEMPLATES, SUPPORTED_CLOUDBEAT_INPUTS, - type PostureInput, - type PosturePolicyTemplate, } from '../../../common/constants'; +import { type PostureInput, type PosturePolicyTemplate } from '../../../common/types'; import { assert } from '../../../common/utils/helpers'; import { cloudPostureIntegrations } from '../../common/constants'; -export const isEksInput = (input: NewPackagePolicyInput) => input.type === CLOUDBEAT_EKS; export const INPUTS_WITH_AWS_VARS = [CLOUDBEAT_EKS, CLOUDBEAT_AWS]; -const defaultInputType: Record = { - kspm: CLOUDBEAT_VANILLA, - cspm: CLOUDBEAT_AWS, -}; -export const getEnabledInputType = (inputs: NewPackagePolicy['inputs']): PostureInput => { - const enabledInput = getEnabledInput(inputs); - - if (enabledInput) return enabledInput.type as PostureInput; - - const policyTemplate = inputs[0].policy_template as PosturePolicyTemplate | undefined; - - if (policyTemplate && SUPPORTED_POLICY_TEMPLATES.includes(policyTemplate)) - return defaultInputType[policyTemplate]; - - throw new Error('unsupported policy template'); -}; - -export const getEnabledInput = ( - inputs: NewPackagePolicy['inputs'] -): NewPackagePolicyInput | undefined => inputs.find((input) => input.enabled); - -export const getUpdatedDeploymentType = (newPolicy: NewPackagePolicy, inputType: PostureInput) => ({ - isValid: true, // TODO: add validations - updatedPolicy: { - ...newPolicy, - inputs: newPolicy.inputs.map((item) => ({ - ...item, - enabled: item.type === inputType, - streams: item.streams.map((stream) => ({ - ...stream, - enabled: item.type === inputType, - })), - })), - }, -}); - -export const getUpdatedEksVar = (newPolicy: NewPackagePolicy, key: string, value: string) => ({ - isValid: true, // TODO: add validations - updatedPolicy: { - ...newPolicy, - inputs: newPolicy.inputs.map((item) => - INPUTS_WITH_AWS_VARS.includes(item.type) ? getUpdatedStreamVars(item, key, value) : item - ), - }, -}); - -// TODO: remove access to first stream -const getUpdatedStreamVars = (item: NewPackagePolicyInput, key: string, value: string) => { - if (!item.streams[0]) return item; - - return { - ...item, - streams: [ - { - ...item.streams[0], - vars: { - ...item.streams[0]?.vars, - [key]: { - ...item.streams[0]?.vars?.[key], - value, - }, - }, - }, - ], - }; -}; -type StreamWithRequiredVars = Array< - NewPackagePolicyInputStream & Required> ->; +type PosturePolicyInput = + | { type: typeof CLOUDBEAT_AZURE; policy_template: 'cspm' } + | { type: typeof CLOUDBEAT_GCP; policy_template: 'cspm' } + | { type: typeof CLOUDBEAT_AWS; policy_template: 'cspm' } + | { type: typeof CLOUDBEAT_VANILLA; policy_template: 'kspm' } + | { type: typeof CLOUDBEAT_EKS; policy_template: 'kspm' }; -// Extend NewPackagePolicyInput with known string literals for input type, policy template and streams -export type NewPackagePolicyPostureInput = NewPackagePolicyInput & - ( - | { type: 'cloudbeat/cis_azure'; policy_template: 'cspm' } - | { type: 'cloudbeat/cis_gcp'; policy_template: 'cspm' } - | { type: 'cloudbeat/cis_k8s'; policy_template: 'kspm' } - | { type: 'cloudbeat/cis_aws'; policy_template: 'cspm'; streams: StreamWithRequiredVars } - | { type: 'cloudbeat/cis_eks'; policy_template: 'kspm'; streams: StreamWithRequiredVars } - ); +// Extend NewPackagePolicyInput with known string literals for input type and policy template +export type NewPackagePolicyPostureInput = NewPackagePolicyInput & PosturePolicyInput; export const isPostureInput = ( input: NewPackagePolicyInput diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/accounts_stats_collector.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/accounts_stats_collector.ts new file mode 100644 index 0000000000000..01637f43a5dea --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/accounts_stats_collector.ts @@ -0,0 +1,241 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { Logger } from '@kbn/core/server'; +import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { calculatePostureScore } from '../../../routes/compliance_dashboard/get_stats'; +import type { CspmAccountsStats } from './types'; +import { LATEST_FINDINGS_INDEX_DEFAULT_NS } from '../../../../common/constants'; + +interface Value { + value: number; +} + +interface DocCount { + doc_count: number; +} + +interface BenchmarkName { + metrics: { 'rule.benchmark.name': string }; +} + +interface BenchmarkId { + metrics: { 'rule.benchmark.id': string }; +} + +interface BenchmarkVersion { + metrics: { 'rule.benchmark.version': string }; +} + +interface AccountsStats { + accounts: { + buckets: AccountEntity[]; + }; +} +interface AccountEntity { + key: string; // account_id + doc_count: number; // latest findings doc count + passed_findings_count: DocCount; + failed_findings_count: DocCount; + benchmark_name: { top: BenchmarkName[] }; + benchmark_id: { top: BenchmarkId[] }; + benchmark_version: { top: BenchmarkVersion[] }; + agents_count: Value; + nodes_count: Value; + pods_count: Value; + resources: { + pods_count: Value; + }; +} + +const getAccountsStatsQuery = (index: string): SearchRequest => ({ + index, + query: { + match_all: {}, + }, + aggs: { + accounts: { + terms: { + field: 'cluster_id', + order: { + _count: 'desc', + }, + size: 100, + }, + aggs: { + nodes_count: { + cardinality: { + field: 'host.name', + }, + }, + agents_count: { + cardinality: { + field: 'agent.id', + }, + }, + benchmark_id: { + top_metrics: { + metrics: { + field: 'rule.benchmark.id', + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }, + benchmark_version: { + top_metrics: { + metrics: { + field: 'rule.benchmark.version', + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }, + benchmark_name: { + top_metrics: { + metrics: { + field: 'rule.benchmark.name', + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, + }, + }, + passed_findings_count: { + filter: { + bool: { + filter: [ + { + bool: { + should: [ + { + term: { + 'result.evaluation': 'passed', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + failed_findings_count: { + filter: { + bool: { + filter: [ + { + bool: { + should: [ + { + term: { + 'result.evaluation': 'failed', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + resources: { + filter: { + bool: { + filter: [ + { + bool: { + should: [ + { + term: { + 'resource.sub_type': 'Pod', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + aggs: { + pods_count: { + cardinality: { + field: 'resource.id', + }, + }, + }, + }, + }, + }, + }, + + size: 0, + _source: false, +}); + +const getCspmAccountsStats = ( + aggregatedResourcesStats: AccountsStats, + logger: Logger +): CspmAccountsStats[] => { + const accounts = aggregatedResourcesStats.accounts.buckets; + + const cspmAccountsStats = accounts.map((account) => ({ + account_id: account.key, + latest_findings_doc_count: account.doc_count, + posture_score: calculatePostureScore( + account.passed_findings_count.doc_count, + account.failed_findings_count.doc_count + ), + passed_findings_count: account.passed_findings_count.doc_count, + failed_findings_count: account.failed_findings_count.doc_count, + benchmark_name: account.benchmark_name.top[0].metrics['rule.benchmark.name'], + benchmark_id: account.benchmark_id.top[0].metrics['rule.benchmark.id'], + benchmark_version: account.benchmark_version.top[0].metrics['rule.benchmark.version'], + agents_count: account.agents_count.value, + nodes_count: account.nodes_count.value, + pods_count: account.resources.pods_count.value, + })); + logger.info('CSPM telemetry: accounts stats was sent'); + + return cspmAccountsStats; +}; + +export const getAccountsStats = async ( + esClient: ElasticsearchClient, + logger: Logger +): Promise => { + try { + const isIndexExists = await esClient.indices.exists({ + index: LATEST_FINDINGS_INDEX_DEFAULT_NS, + }); + + if (isIndexExists) { + const accountsStatsResponse = await esClient.search( + getAccountsStatsQuery(LATEST_FINDINGS_INDEX_DEFAULT_NS) + ); + + const cspmAccountsStats = accountsStatsResponse.aggregations + ? getCspmAccountsStats(accountsStatsResponse.aggregations, logger) + : []; + + return cspmAccountsStats; + } + + return []; + } catch (e) { + logger.error(`Failed to get resources stats ${e}`); + return []; + } +}; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/indices_stats_collector.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/indices_stats_collector.ts index a1bb49216262f..0feacc82c42a2 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/indices_stats_collector.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/indices_stats_collector.ts @@ -4,29 +4,36 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; import type { Logger } from '@kbn/core/server'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { CspmIndicesStats, IndexStats } from './types'; import { BENCHMARK_SCORE_INDEX_DEFAULT_NS, FINDINGS_INDEX_DEFAULT_NS, LATEST_FINDINGS_INDEX_DEFAULT_NS, } from '../../../../common/constants'; -import type { CspmIndicesStats, IndexStats } from './types'; -export const getIndicesStats = async ( +const getIndexDocCount = (esClient: ElasticsearchClient, index: string) => + esClient.indices.stats({ index }); + +const getLatestDocTimestamp = async ( esClient: ElasticsearchClient, - logger: Logger -): Promise => { - const [findings, latestFindings, score] = await Promise.all([ - getIndexStats(esClient, FINDINGS_INDEX_DEFAULT_NS, logger), - getIndexStats(esClient, LATEST_FINDINGS_INDEX_DEFAULT_NS, logger), - getIndexStats(esClient, BENCHMARK_SCORE_INDEX_DEFAULT_NS, logger), - ]); - return { - findings, - latest_findings: latestFindings, - score, - }; + index: string +): Promise => { + const latestTimestamp = await esClient.search({ + index, + query: { + match_all: {}, + }, + sort: '@timestamp:desc', + size: 1, + fields: ['@timestamp'], + _source: false, + }); + + const latestEventTimestamp = latestTimestamp.hits?.hits[0]?.fields; + + return latestEventTimestamp ? latestEventTimestamp['@timestamp'][0] : null; }; const getIndexStats = async ( @@ -60,25 +67,18 @@ const getIndexStats = async ( } }; -const getIndexDocCount = (esClient: ElasticsearchClient, index: string) => - esClient.indices.stats({ index }); - -const getLatestDocTimestamp = async ( +export const getIndicesStats = async ( esClient: ElasticsearchClient, - index: string -): Promise => { - const latestTimestamp = await esClient.search({ - index, - query: { - match_all: {}, - }, - sort: '@timestamp:desc', - size: 1, - fields: ['@timestamp'], - _source: false, - }); - - const latestEventTimestamp = latestTimestamp.hits?.hits[0]?.fields; - - return latestEventTimestamp ? latestEventTimestamp['@timestamp'][0] : null; + logger: Logger +): Promise => { + const [findings, latestFindings, score] = await Promise.all([ + getIndexStats(esClient, FINDINGS_INDEX_DEFAULT_NS, logger), + getIndexStats(esClient, LATEST_FINDINGS_INDEX_DEFAULT_NS, logger), + getIndexStats(esClient, BENCHMARK_SCORE_INDEX_DEFAULT_NS, logger), + ]); + return { + findings, + latest_findings: latestFindings, + score, + }; }; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts index 5f5de23434145..9eeb3338a9035 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/register.ts @@ -11,6 +11,7 @@ import { getIndicesStats } from './indices_stats_collector'; import { getResourcesStats } from './resources_stats_collector'; import { cspmUsageSchema } from './schema'; import { CspmUsage } from './types'; +import { getAccountsStats } from './accounts_stats_collector'; export function registerCspmUsageCollector( logger: Logger, @@ -26,13 +27,15 @@ export function registerCspmUsageCollector( type: 'cloud_security_posture', isReady: () => true, fetch: async (collectorFetchContext: CollectorFetchContext) => { - const [indicesStats, resourcesStats] = await Promise.all([ + const [indicesStats, accountsStats, resourcesStats] = await Promise.all([ getIndicesStats(collectorFetchContext.esClient, logger), - await getResourcesStats(collectorFetchContext.esClient, logger), + getAccountsStats(collectorFetchContext.esClient, logger), + getResourcesStats(collectorFetchContext.esClient, logger), ]); return { indices: indicesStats, + accounts_stats: accountsStats, resources_stats: resourcesStats, }; }, diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/resources_stats_collector.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/resources_stats_collector.ts index 53b7f2af26c8d..3802f6651cd19 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/resources_stats_collector.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/resources_stats_collector.ts @@ -110,7 +110,10 @@ const getEvaluationStats = (resourceSubType: ResourceSubType) => { return { passed_findings_count: passed, failed_findings_count: failed }; }; -const getCspmResourcesStats = (aggregatedResourcesStats: ResourcesStats): CspmResourcesStats[] => { +const getCspmResourcesStats = ( + aggregatedResourcesStats: ResourcesStats, + logger: Logger +): CspmResourcesStats[] => { const accounts = aggregatedResourcesStats.accounts.buckets; const resourcesStats = accounts.map((account) => { @@ -129,6 +132,8 @@ const getCspmResourcesStats = (aggregatedResourcesStats: ResourcesStats): CspmRe }); }); }); + logger.info('CSPM telemetry: resources stats was sent'); + return resourcesStats.flat(2); }; @@ -147,7 +152,7 @@ export const getResourcesStats = async ( ); const cspmResourcesStats = resourcesStatsResponse.aggregations - ? getCspmResourcesStats(resourcesStatsResponse.aggregations) + ? getCspmResourcesStats(resourcesStatsResponse.aggregations, logger) : []; return cspmResourcesStats; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts index 9e25309369934..b7ed05f4532ce 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/schema.ts @@ -65,4 +65,20 @@ export const cspmUsageSchema: MakeSchemaFrom = { failed_findings_count: { type: 'long' }, }, }, + accounts_stats: { + type: 'array', + items: { + account_id: { type: 'keyword' }, + posture_score: { type: 'long' }, + latest_findings_doc_count: { type: 'long' }, + benchmark_id: { type: 'keyword' }, + benchmark_name: { type: 'keyword' }, + benchmark_version: { type: 'keyword' }, + passed_findings_count: { type: 'long' }, + failed_findings_count: { type: 'long' }, + agents_count: { type: 'short' }, + nodes_count: { type: 'short' }, + pods_count: { type: 'short' }, + }, + }, }; diff --git a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts index c17f07cb6cdc1..58e2932c231bb 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/telemetry/collectors/types.ts @@ -8,6 +8,7 @@ export interface CspmUsage { indices: CspmIndicesStats; resources_stats: CspmResourcesStats[]; + accounts_stats: CspmAccountsStats[]; } export interface CspmIndicesStats { @@ -32,3 +33,16 @@ export interface CspmResourcesStats { passed_findings_count: number; failed_findings_count: number; } +export interface CspmAccountsStats { + account_id: string; + posture_score: number; + latest_findings_doc_count: number; + benchmark_id: string; + benchmark_name: string; + benchmark_version: string; + passed_findings_count: number; + failed_findings_count: number; + agents_count: number; + nodes_count: number; + pods_count: number; +} diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 6c87e894e5777..03f072747a33d 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -266,19 +266,13 @@ const getSavedObjectTypes = ( }, }, installed_kibana: { - type: 'nested', - properties: { - id: { type: 'keyword' }, - type: { type: 'keyword' }, - }, + type: 'object', + enabled: false, }, installed_kibana_space_id: { type: 'keyword' }, package_assets: { - type: 'nested', - properties: { - id: { type: 'keyword' }, - type: { type: 'keyword' }, - }, + type: 'object', + enabled: false, }, install_started_at: { type: 'date' }, install_version: { type: 'keyword' }, diff --git a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts index c6176b94f5fd2..3b6f38971eff7 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts @@ -102,10 +102,9 @@ export async function removeArchiveEntries(opts: { }) { const { savedObjectsClient, refs } = opts; if (!refs) return; - const results = await Promise.all( - refs.map((ref) => savedObjectsClient.delete(ASSETS_SAVED_OBJECT_TYPE, ref.id)) + return savedObjectsClient.bulkDelete( + refs.map((ref) => ({ id: ref.id, type: ASSETS_SAVED_OBJECT_TYPE })) ); - return results; } export async function saveArchiveEntries(opts: { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts index 61780c7977166..4a4183965913a 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts @@ -125,11 +125,8 @@ async function deleteKibanaAssets( // in the case of a partial install, it is expected that some assets will be not found // we filter these out before calling delete const assetsToDelete = foundObjects.map(({ saved_object: { id, type } }) => ({ id, type })); - const promises = assetsToDelete.map(async ({ id, type }) => { - return savedObjectsClient.delete(type, id, { namespace }); - }); - return Promise.all(promises); + return savedObjectsClient.bulkDelete(assetsToDelete, { namespace }); } function deleteESAssets( diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index c36c55611363a..a1643a5070634 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -471,3 +471,24 @@ export const RISKY_USERS_DOC_LINK = export const MAX_NUMBER_OF_NEW_TERMS_FIELDS = 3; export const BULK_ADD_TO_TIMELINE_LIMIT = 2000; + +export const DEFAULT_DETECTION_PAGE_FILTERS = [ + { + title: 'Status', + fieldName: 'kibana.alert.workflow_status', + selectedOptions: ['open'], + }, + { + title: 'Severity', + fieldName: 'kibana.alert.severity', + selectedOptions: [], + }, + { + title: 'User', + fieldName: 'user.name', + }, + { + title: 'Host', + fieldName: 'host.name', + }, +]; diff --git a/x-pack/plugins/security_solution/common/utils/format_page_filter_search_param.ts b/x-pack/plugins/security_solution/common/utils/format_page_filter_search_param.ts new file mode 100644 index 0000000000000..eb33e8cd629fe --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/format_page_filter_search_param.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import rison from '@kbn/rison'; +import type { FilterItemObj } from '../../public/common/components/filter_group/types'; + +export const formatPageFilterSearchParam = (filters: FilterItemObj[]) => { + const modifiedFilters = filters.map((filter) => ({ + title: filter.title ?? filter.fieldName, + selectedOptions: filter.selectedOptions ?? [], + fieldName: filter.fieldName, + existsSelected: filter.existsSelected ?? false, + exclude: filter.exclude ?? false, + })); + return rison.encode(modifiedFilters); +}; diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/changing_alert_status.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/changing_alert_status.cy.ts index 692c81bd58381..19d1a24cd4f21 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/changing_alert_status.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/changing_alert_status.cy.ts @@ -66,6 +66,7 @@ describe('Changing alert status', () => { .then((numberOfOpenedAlertsText) => { const numberOfOpenedAlerts = parseInt(numberOfOpenedAlertsText, 10); goToClosedAlerts(); + waitForAlerts(); cy.get(ALERTS_COUNT) .invoke('text') .then((alertNumberString) => { @@ -96,6 +97,8 @@ describe('Changing alert status', () => { goToOpenedAlerts(); waitForAlerts(); + selectCountTable(); + cy.get(ALERTS_COUNT).should( 'have.text', `${numberOfOpenedAlerts + numberOfAlertsToBeOpened} alerts`.toString() diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts new file mode 100644 index 0000000000000..c246f28ac09b3 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getNewRule } from '../../objects/rule'; +import { + CONTROL_FRAMES, + OPTION_LIST_LABELS, + OPTION_LIST_VALUES, + OPTION_SELECTABLE, +} from '../../screens/common/filter_group'; +import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { login, visit } from '../../tasks/login'; +import { ALERTS_URL } from '../../urls/navigation'; +import { APP_ID, DEFAULT_DETECTION_PAGE_FILTERS } from '../../../common/constants'; +import { formatPageFilterSearchParam } from '../../../common/utils/format_page_filter_search_param'; +import { + markAcknowledgedFirstAlert, + resetFilters, + selectCountTable, + waitForAlerts, + waitForPageFilters, +} from '../../tasks/alerts'; +import { ALERTS_COUNT } from '../../screens/alerts'; +import { navigateFromHeaderTo } from '../../tasks/security_header'; +import { ALERTS, CASES } from '../../screens/security_header'; + +const assertFilterControlsWithFilterObject = (filterObject = DEFAULT_DETECTION_PAGE_FILTERS) => { + cy.log(JSON.stringify({ filterObject })); + + cy.get(CONTROL_FRAMES).should((sub) => { + expect(sub.length).eq(4); + }); + + cy.get(OPTION_LIST_LABELS).should((sub) => { + filterObject.forEach((filter, idx) => { + expect(sub.eq(idx).text()).eq(filter.title); + }); + }); + + cy.get(OPTION_LIST_VALUES).should((sub) => { + filterObject.forEach((filter, idx) => { + expect(sub.eq(idx).text().replace(',', '')).eq( + filter.selectedOptions && filter.selectedOptions.length > 0 + ? filter.selectedOptions.join('') + : 'Any' + ); + }); + }); +}; + +describe('Detections : Page Filters', () => { + before(() => { + cleanKibana(); + login(); + createCustomRuleEnabled(getNewRule(), 'custom_rule_filters'); + visit(ALERTS_URL); + waitForAlerts(); + waitForPageFilters(); + }); + + afterEach(() => { + cy.clearLocalStorage(`${APP_ID}.pageFilters`); + resetFilters(); + }); + + it('Default page filters are populated when nothing is provided in the URL', () => { + assertFilterControlsWithFilterObject(); + }); + + it('Page filters are loaded with custom values provided in the URL', () => { + const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.map((filter) => { + if (filter.title === 'Status') { + filter.selectedOptions = ['open', 'acknowledged']; + } + return filter; + }); + + cy.url().then((url) => { + const currURL = new URL(url); + + currURL.searchParams.set('pageFilters', formatPageFilterSearchParam(NEW_FILTERS)); + cy.visit(currURL.toString()); + waitForAlerts(); + assertFilterControlsWithFilterObject(NEW_FILTERS); + }); + }); + + it('Page filters are loaded with custom filters and values', () => { + const CUSTOM_URL_FILTER = [ + { + title: 'Process', + fieldName: 'process.name', + selectedOptions: ['testing123'], + }, + ]; + + const pageFilterUrlString = formatPageFilterSearchParam(CUSTOM_URL_FILTER); + + cy.url().then((url) => { + const currURL = new URL(url); + + currURL.searchParams.set('pageFilters', pageFilterUrlString); + cy.visit(currURL.toString()); + + waitForAlerts(); + cy.get(OPTION_LIST_LABELS).should((sub) => { + DEFAULT_DETECTION_PAGE_FILTERS.forEach((filter, idx) => { + if (idx === DEFAULT_DETECTION_PAGE_FILTERS.length - 1) { + expect(sub.eq(idx).text()).eq(CUSTOM_URL_FILTER[0].title); + } else { + expect(sub.eq(idx).text()).eq(filter.title); + } + }); + }); + }); + }); + + it('URL is updated when ever page filters are loaded', (done) => { + cy.on('url:changed', () => { + const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.map((filter) => { + if (filter.title === 'Status') { + filter.selectedOptions = []; + } + return filter; + }); + cy.url().should('have.text', formatPageFilterSearchParam(NEW_FILTERS)); + done(); + }); + cy.get(OPTION_LIST_VALUES).eq(0).click(); + + // unselect status open + cy.get(OPTION_SELECTABLE(0, 'open')).trigger('click', { force: true }); + }); + + it(`Alert list is updated when the alerts are updated`, () => { + // mark status of one alert to be acknowledged + selectCountTable(); + cy.get(ALERTS_COUNT) + .invoke('text') + .then((noOfAlerts) => { + const originalAlertCount = noOfAlerts.split(' ')[0]; + markAcknowledgedFirstAlert(); + cy.reload(); + waitForAlerts(); + cy.get(OPTION_LIST_VALUES).eq(0).click(); + cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible'); + cy.get(ALERTS_COUNT) + .invoke('text') + .should((newAlertCount) => { + expect(newAlertCount.split(' ')[0]).eq(String(parseInt(originalAlertCount, 10) - 1)); + }); + }); + }); + + it(`URL is updated when filters are updated`, (done) => { + const NEW_FILTERS = DEFAULT_DETECTION_PAGE_FILTERS.map((filter) => { + if (filter.title === 'Severity') { + filter.selectedOptions = ['high']; + } + return filter; + }); + + cy.on('url:changed', () => { + // we want assertion to run only once URL Changes. + cy.url().should('have.text', formatPageFilterSearchParam(NEW_FILTERS)); + done(); + }); + cy.get(OPTION_LIST_VALUES).eq(1).click(); + cy.get(OPTION_SELECTABLE(1, 'high')).should('be.visible'); + cy.get(OPTION_SELECTABLE(1, 'high')).click({ force: true }); + }); + + it(`Filters are restored from localstorage when user navigates back to the page.`, () => { + // change severity filter to high + cy.get(OPTION_LIST_VALUES).eq(1).click(); + cy.get(OPTION_SELECTABLE(1, 'high')).should('be.visible'); + cy.get(OPTION_SELECTABLE(1, 'high')).click({ force: true }); + + // high should be scuccessfully selected. + cy.get(OPTION_LIST_VALUES).eq(1).contains('high'); + + navigateFromHeaderTo(CASES); // navigate away from alert page + + navigateFromHeaderTo(ALERTS); // navigate back to alert page + + waitForPageFilters(); + + cy.get(OPTION_LIST_VALUES).eq(0).contains('open'); // status should be Open as previously selected + cy.get(OPTION_LIST_VALUES).eq(1).contains('high'); // severity should be low as previously selected + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts index c7ec942497c50..e00e1aa083151 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts @@ -11,7 +11,10 @@ import { getNewRule } from '../../../objects/rule'; import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; -import { goToClosedAlerts, goToOpenedAlerts } from '../../../tasks/alerts'; +import { + goToClosedAlertsOnRuleDetailsPage, + goToOpenedAlertsOnRuleDetailsPage, +} from '../../../tasks/alerts'; import { esArchiverLoad, esArchiverUnload, @@ -312,7 +315,7 @@ describe('Add/edit exception from rule details', () => { cy.get(EMPTY_ALERT_TABLE).should('exist'); // Closed alert should appear in table - goToClosedAlerts(); + goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); @@ -329,7 +332,7 @@ describe('Add/edit exception from rule details', () => { // now that there are no more exceptions, the docs should match and populate alerts goToAlertsTab(); - goToOpenedAlerts(); + goToOpenedAlertsOnRuleDetailsPage(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts index 79f9945219dc2..6f821af844819 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts @@ -12,8 +12,8 @@ import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { addExceptionFromFirstAlert, - goToClosedAlerts, - goToOpenedAlerts, + goToClosedAlertsOnRuleDetailsPage, + goToOpenedAlertsOnRuleDetailsPage, } from '../../../tasks/alerts'; import { addExceptionConditions, @@ -108,7 +108,7 @@ describe('Add exception using data views from rule details', () => { cy.get(EMPTY_ALERT_TABLE).should('exist'); // Closed alert should appear in table - goToClosedAlerts(); + goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); @@ -125,7 +125,7 @@ describe('Add exception using data views from rule details', () => { // now that there are no more exceptions, the docs should match and populate alerts goToAlertsTab(); - goToOpenedAlerts(); + goToOpenedAlertsOnRuleDetailsPage(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); @@ -158,7 +158,7 @@ describe('Add exception using data views from rule details', () => { cy.get(EMPTY_ALERT_TABLE).should('exist'); // Closed alert should appear in table - goToClosedAlerts(); + goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); @@ -175,7 +175,7 @@ describe('Add exception using data views from rule details', () => { // now that there are no more exceptions, the docs should match and populate alerts goToAlertsTab(); - goToOpenedAlerts(); + goToOpenedAlertsOnRuleDetailsPage(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index acb2f47bcf27c..4ab83c7ef69e2 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -102,6 +102,8 @@ export const TAKE_ACTION_POPOVER_BTN = '[data-test-subj="selectedShowBulkActions export const TIMELINE_CONTEXT_MENU_BTN = '[data-test-subj="timeline-context-menu-button"]'; +export const TIMELINE_CONTEXT_MENU = '[data-test-subj="actions-context-menu"]'; + export const USER_NAME = '[data-test-subj^=formatted-field][data-test-subj$=user\\.name]'; export const ATTACH_ALERT_TO_CASE_BUTTON = '[data-test-subj="add-to-existing-case-action"]'; @@ -121,3 +123,10 @@ export const USER_RISK_HEADER_COLIMN = export const USER_RISK_COLUMN = '[data-gridcell-column-id="user.risk.calculated_level"]'; export const ACTION_COLUMN = '[data-gridcell-column-id="default-timeline-control-column"]'; + +export const DATAGRID_CHANGES_IN_PROGRESS = '[data-test-subj="body-data-grid"] .euiProgress'; + +export const EVENT_CONTAINER_TABLE_LOADING = '[data-test-subj="events-container-loading-true"]'; + +export const EVENT_CONTAINER_TABLE_NOT_LOADING = + '[data-test-subj="events-container-loading-false"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts b/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts new file mode 100644 index 0000000000000..18fcbb7d52c0b --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/common/filter_group.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const FILTER_GROUP_LOADING = '[data-test-subj="filter-group__loading"]'; +export const FILTER_GROUP_ITEMS = '[data-test-subj="filter-group__items"]'; +export const FILTER_GROUP_CLEAR = '[data-test-subj="filter-group__clear"]'; + +export const CONTROL_FRAMES = '[data-test-subj="control-frame"]'; + +export const OPTION_LIST_LABELS = '.controlFrame__labelToolTip'; + +export const OPTION_LIST_VALUES = '.euiFilterButton__textShift'; + +export const OPTION_LIST_NUMBER_OFF = '.euiFilterButton__notification'; + +export const OPTION_LISTS_LOADING = '.optionsList--filterBtnWrapper .euiLoadingSpinner'; + +export const OPTION_LIST_ACTIVE_CLEAR_SELECTION = + '[data-test-subj="optionsList-control-clear-all-selections"]'; + +export const OPTION_SELECTABLE = (popoverIndex: number, value: string) => + `#control-popover-${popoverIndex} [data-test-subj="optionsList-control-selection-${value}"]`; + +export const OPTION_IGNORED = (popoverIndex: number, value: string) => + `#control-popover-${popoverIndex} [data-test-subj="optionsList-control-ignored-selection-${value}"]`; + +export const DETECTION_PAGE_FILTER_GROUP_WRAPPER = '.filter-group__wrapper'; + +export const DETECTION_PAGE_FILTERS_LOADING = '.securityPageWrapper .controlFrame--controlLoading'; + +export const DETECTION_PAGE_FILTER_GROUP_LOADING = '[data-test-subj="filter-group__loading"]'; + +export const DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU = '[data-test-subj="filter-group__context"]'; + +export const DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON = + '[data-test-subj="filter-group__context--reset"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index 1e7b3047edaa2..715c071b9fb68 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -11,15 +11,12 @@ import { CHART_SELECT, CLOSE_ALERT_BTN, CLOSE_SELECTED_ALERTS_BTN, - CLOSED_ALERTS_FILTER_BTN, EXPAND_ALERT_BTN, GROUP_BY_TOP_INPUT, - ACKNOWLEDGED_ALERTS_FILTER_BTN, LOADING_ALERTS_PANEL, MANAGE_ALERT_DETECTION_RULES_BTN, MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, - OPENED_ALERTS_FILTER_BTN, SEND_ALERT_TO_TIMELINE_BTN, SELECT_TABLE, TAKE_ACTION_POPOVER_BTN, @@ -29,8 +26,12 @@ import { TAKE_ACTION_BTN, TAKE_ACTION_MENU, ADD_ENDPOINT_EXCEPTION_BTN, + DATAGRID_CHANGES_IN_PROGRESS, + EVENT_CONTAINER_TABLE_NOT_LOADING, + CLOSED_ALERTS_FILTER_BTN, + OPENED_ALERTS_FILTER_BTN, } from '../screens/alerts'; -import { REFRESH_BUTTON } from '../screens/security_header'; +import { LOADING_INDICATOR, REFRESH_BUTTON } from '../screens/security_header'; import { ALERT_TABLE_CELL_ACTIONS_ADD_TO_TIMELINE, TIMELINE_COLUMN_SPINNER, @@ -46,7 +47,19 @@ import { USER_DETAILS_LINK, } from '../screens/alerts_details'; import { FIELD_INPUT } from '../screens/exceptions'; +import { + DETECTION_PAGE_FILTERS_LOADING, + DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU, + DETECTION_PAGE_FILTER_GROUP_LOADING, + DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON, + DETECTION_PAGE_FILTER_GROUP_WRAPPER, + OPTION_LISTS_LOADING, + OPTION_LIST_ACTIVE_CLEAR_SELECTION, + OPTION_LIST_VALUES, + OPTION_SELECTABLE, +} from '../screens/common/filter_group'; import { LOADING_SPINNER } from '../screens/common/page'; +import { ALERTS_URL } from '../urls/navigation'; export const addExceptionFromFirstAlert = () => { expandFirstAlertActions(); @@ -57,7 +70,7 @@ export const addExceptionFromFirstAlert = () => { }; export const openAddEndpointExceptionFromFirstAlert = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + expandFirstAlertActions(); cy.root() .pipe(($el) => { $el.find(ADD_ENDPOINT_EXCEPTION_BTN).trigger('click'); @@ -85,11 +98,7 @@ export const openAddExceptionFromAlertDetails = () => { }; export const closeFirstAlert = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN) - .first() - .pipe(($el) => $el.trigger('click')) - .should('be.visible'); - + expandFirstAlertActions(); cy.get(CLOSE_ALERT_BTN) .pipe(($el) => $el.trigger('click')) .should('not.exist'); @@ -107,8 +116,32 @@ export const closeAlerts = () => { }; export const expandFirstAlertActions = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN, { timeout: 10000 }).should('be.visible'); - cy.get(TIMELINE_CONTEXT_MENU_BTN, { timeout: 10000 }).first().click({ force: true }); + waitForAlerts(); + + let count = 0; + + const click = ($el: JQuery) => { + count++; + return $el.trigger('click'); + }; + + /* + * + * Sometimes it takes some time for UI to attach event listener to + * TIMELINE_CONTEXT_MENU_BTN and cypress is too fast to click. + * Becuase of this popover does not open when click. + * pipe().should() makes sure that pipe function is repeated until should becomes true + * + * */ + + cy.get(TIMELINE_CONTEXT_MENU_BTN) + .first() + .should('be.visible') + .pipe(click) + .should('have.attr', 'data-popover-open', 'true') + .then(() => { + cy.log(`Clicked ${count} times`); + }); }; export const expandFirstAlert = () => { @@ -136,26 +169,65 @@ export const setEnrichmentDates = (from?: string, to?: string) => { cy.get(UPDATE_ENRICHMENT_RANGE_BUTTON).click(); }; -export const goToClosedAlerts = () => { +export const refreshAlertPageFilter = () => { + // currently there is no consistent way to refresh the filters. + // Have raised this with the kibana presentation team who provided this filter group plugin + cy.reload(); + waitForAlerts(); +}; + +export const togglePageFilterPopover = (filterIndex: number) => { + cy.get(OPTION_LIST_VALUES).eq(filterIndex).click({ force: true }); +}; + +export const clearAllSelections = () => { + cy.get(OPTION_LIST_ACTIVE_CLEAR_SELECTION).click({ force: true }); +}; + +export const selectPageFilterValue = (filterIndex: number, ...values: string[]) => { + refreshAlertPageFilter(); + togglePageFilterPopover(filterIndex); + clearAllSelections(); + values.forEach((value) => { + cy.get(OPTION_SELECTABLE(filterIndex, value)).click({ force: true }); + }); + waitForAlerts(); + togglePageFilterPopover(filterIndex); +}; + +export const goToClosedAlertsOnRuleDetailsPage = () => { cy.get(CLOSED_ALERTS_FILTER_BTN).click(); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; +export const goToClosedAlerts = () => { + selectPageFilterValue(0, 'closed'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); + cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); +}; + export const goToManageAlertsDetectionRules = () => { cy.get(MANAGE_ALERT_DETECTION_RULES_BTN).should('exist').click({ force: true }); }; -export const goToOpenedAlerts = () => { +export const goToOpenedAlertsOnRuleDetailsPage = () => { cy.get(OPENED_ALERTS_FILTER_BTN).click({ force: true }); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); }; +export const goToOpenedAlerts = () => { + selectPageFilterValue(0, 'open'); + cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); +}; + export const openFirstAlert = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); - cy.get(OPEN_ALERT_BTN).click(); + expandFirstAlertActions(); + cy.get(OPEN_ALERT_BTN).should('be.visible').click({ force: true }); }; export const openAlerts = () => { @@ -174,18 +246,19 @@ export const clearGroupByTopInput = () => { }; export const goToAcknowledgedAlerts = () => { - cy.get(ACKNOWLEDGED_ALERTS_FILTER_BTN).click(); + selectPageFilterValue(0, 'acknowledged'); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); cy.get(REFRESH_BUTTON).should('have.attr', 'aria-label', 'Refresh query'); cy.get(TIMELINE_COLUMN_SPINNER).should('not.exist'); }; export const markAcknowledgedFirstAlert = () => { - cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); + expandFirstAlertActions(); cy.get(MARK_ALERT_ACKNOWLEDGED_BTN).click(); }; export const selectNumberOfAlerts = (numberOfAlerts: number) => { + waitForAlerts(); for (let i = 0; i < numberOfAlerts; i++) { cy.get(ALERT_CHECKBOX).eq(i).click({ force: true }); } @@ -205,7 +278,11 @@ export const addAlertPropertyToTimeline = (propertySelector: string, rowIndex: n }; export const waitForAlerts = () => { + waitForPageFilters(); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); + cy.get(DATAGRID_CHANGES_IN_PROGRESS).should('not.be.true'); + cy.get(EVENT_CONTAINER_TABLE_NOT_LOADING).should('be.visible'); + cy.get(LOADING_INDICATOR).should('not.exist'); }; export const waitForAlertsPanelToBeLoaded = () => { @@ -230,3 +307,25 @@ export const scrollAlertTableColumnIntoView = (columnSelector: string) => { export const openUserDetailsFlyout = () => { cy.get(CELL_EXPANSION_POPOVER).find(USER_DETAILS_LINK).click(); }; + +export const waitForPageFilters = () => { + cy.log('Waiting for Page Filters'); + cy.url().then((urlString) => { + const url = new URL(urlString); + if (url.pathname.endsWith(ALERTS_URL)) { + // since these are only valid on the alert page + cy.get(DETECTION_PAGE_FILTER_GROUP_WRAPPER).should('exist'); + cy.get(DETECTION_PAGE_FILTER_GROUP_LOADING).should('not.exist'); + cy.get(DETECTION_PAGE_FILTERS_LOADING).should('not.exist'); + cy.get(OPTION_LISTS_LOADING).should('have.lengthOf', 0); + } else { + cy.log('Skipping Page Filters Wait'); + } + }); +}; + +export const resetFilters = () => { + cy.get(DETECTION_PAGE_FILTER_GROUP_CONTEXT_MENU).click({ force: true }); + cy.get(DETECTION_PAGE_FILTER_GROUP_RESET_BUTTON).click({ force: true }); + waitForPageFilters(); +}; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index bddfc36c7d61d..854ece9ac26ed 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -33,7 +33,8 @@ "triggersActionsUi", "uiActions", "unifiedSearch", - "files" + "files", + "controls" ], "optionalPlugins": [ "cloudExperiments", diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filter_update_to_url_sync.ts b/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filter_update_to_url_sync.ts new file mode 100644 index 0000000000000..642a382661804 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filter_update_to_url_sync.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ControlGroupInput, + ControlPanelState, + OptionsListEmbeddableInput, +} from '@kbn/controls-plugin/common'; +import { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { formatPageFilterSearchParam } from '../../../../../common/utils/format_page_filter_search_param'; +import { URL_PARAM_KEY } from '../../../hooks/use_url_state'; +import { updateUrlParam } from '../../../store/global_url_param/actions'; +import type { FilterItemObj } from '../types'; + +export interface UseFilterUrlSyncParams { + controlGroupInput: ControlGroupInput | undefined; +} + +export const useFilterUpdatesToUrlSync = ({ controlGroupInput }: UseFilterUrlSyncParams) => { + const dispatch = useDispatch(); + + const formattedFilters: FilterItemObj[] | undefined = useMemo(() => { + if (!controlGroupInput) return; + const { panels } = controlGroupInput; + return Object.keys(panels).map((panelId) => { + const { + explicitInput: { fieldName, selectedOptions, title, existsSelected, exclude }, + } = panels[panelId] as ControlPanelState; + return { + fieldName: fieldName as string, + selectedOptions: selectedOptions ?? [], + title, + existsSelected, + exclude, + }; + }); + }, [controlGroupInput]); + + useEffect(() => { + if (!formattedFilters) return; + dispatch( + updateUrlParam({ + key: URL_PARAM_KEY.pageFilter, + value: formatPageFilterSearchParam(formattedFilters), + }) + ); + }, [formattedFilters, dispatch]); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filters.ts b/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filters.ts new file mode 100644 index 0000000000000..d03374930f7ba --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/hooks/use_filters.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useContext } from 'react'; +import { FilterContext } from '..'; + +export const useFilters = () => { + const context = useContext(FilterContext); + + if (!context) { + throw new Error('hook must be used with in FilterGroup Component'); + } + + return context; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/index.scss b/x-pack/plugins/security_solution/public/common/components/filter_group/index.scss new file mode 100644 index 0000000000000..dbc1b992c7a6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/index.scss @@ -0,0 +1,11 @@ +[class*=options_list_popover_footer--OptionsListPopoverFooter] { + display: none; +} + +[data-test-subj=optionsListControl__sortingOptionsButton] { + display: none +} + +[id^=control-popover] .euiPopoverTitle { + display: none +} diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/index.tsx b/x-pack/plugins/security_solution/public/common/components/filter_group/index.tsx new file mode 100644 index 0000000000000..96f9c28300a16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/index.tsx @@ -0,0 +1,367 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + ControlGroupInput, + ControlGroupContainer, + controlGroupInputBuilder, + ControlGroupOutput, + OptionsListEmbeddableInput, +} from '@kbn/controls-plugin/public'; +import { LazyControlGroupRenderer } from '@kbn/controls-plugin/public'; +import type { PropsWithChildren } from 'react'; +import React, { createContext, useCallback, useEffect, useState, useRef, useMemo } from 'react'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import type { Subscription } from 'rxjs'; +import styled from 'styled-components'; +import { cloneDeep, debounce } from 'lodash'; +import { withSuspense } from '@kbn/shared-ux-utility'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { useInitializeUrlParam } from '../../utils/global_query_string'; +import { URL_PARAM_KEY } from '../../hooks/use_url_state'; +import type { FilterContextType, FilterGroupProps, FilterItemObj } from './types'; +import { useFilterUpdatesToUrlSync } from './hooks/use_filter_update_to_url_sync'; +import { APP_ID } from '../../../../common/constants'; +import './index.scss'; +import { FilterGroupLoading } from './loading'; +import { withSpaceId } from '../with_space_id'; + +type ControlGroupBuilder = typeof controlGroupInputBuilder; + +const ControlGroupRenderer = withSuspense(LazyControlGroupRenderer); + +export const FilterContext = createContext(undefined); + +const FilterWrapper = styled.div.attrs((props) => ({ + className: props.className, +}))` + & .euiFilterButton-hasActiveFilters { + font-weight: 400; + } + + & .controlGroup { + min-height: 40px; + } +`; + +const FilterGroupComponent = (props: PropsWithChildren) => { + const { + dataViewId, + onFilterChange, + timeRange, + filters, + query, + chainingSystem = 'HIERARCHICAL', + initialControls, + spaceId, + } = props; + + const filterChangedSubscription = useRef(); + const inputChangedSubscription = useRef(); + + const [controlGroup, setControlGroup] = useState(); + + const localStoragePageFilterKey = useMemo( + () => `${APP_ID}.${spaceId}.${URL_PARAM_KEY.pageFilter}`, + [spaceId] + ); + + const [controlGroupInputUpdates, setControlGroupInputUpdates] = useLocalStorage< + ControlGroupInput | undefined + >(localStoragePageFilterKey, undefined); + + const [initialUrlParam, setInitialUrlParam] = useState(); + + const urlDataApplied = useRef(false); + + const [isContextMenuVisible, setIsContextMenuVisible] = useState(false); + + const toggleContextMenu = useCallback(() => { + setIsContextMenuVisible((prev) => !prev); + }, []); + + const onUrlParamInit = (param: FilterItemObj[] | null) => { + if (param == null) return; + try { + setInitialUrlParam(param); + } catch (err) { + // if there is an error ignore url Param + // eslint-disable-next-line no-console + console.error(err); + } + }; + + useInitializeUrlParam(URL_PARAM_KEY.pageFilter, onUrlParamInit); + + useEffect(() => { + const cleanup = () => { + if (filterChangedSubscription.current) { + filterChangedSubscription.current.unsubscribe(); + } + if (inputChangedSubscription.current) { + inputChangedSubscription.current.unsubscribe(); + } + }; + return cleanup; + }, []); + + useEffect(() => { + controlGroup?.updateInput({ + timeRange, + filters, + query, + chainingSystem, + }); + }, [timeRange, filters, query, chainingSystem, controlGroup]); + + const handleFilterUpdates = useCallback( + ({ filters: newFilters }: ControlGroupOutput) => { + if (onFilterChange) onFilterChange(newFilters ?? []); + }, + [onFilterChange] + ); + + const debouncedFilterUpdates = useMemo( + () => debounce(handleFilterUpdates, 500), + [handleFilterUpdates] + ); + + const handleInputUpdates = useCallback( + (newInput: ControlGroupInput) => { + setControlGroupInputUpdates(newInput); + }, + [setControlGroupInputUpdates] + ); + + const debouncedInputUpdatesHandler = useMemo( + () => debounce(handleInputUpdates, 500), + [handleInputUpdates] + ); + + useEffect(() => { + if (!controlGroup) return; + controlGroup.reload(); + filterChangedSubscription.current = controlGroup.getOutput$().subscribe({ + next: debouncedFilterUpdates, + }); + + inputChangedSubscription.current = controlGroup.getInput$().subscribe({ + next: debouncedInputUpdatesHandler, + }); + }, [controlGroup, debouncedFilterUpdates, debouncedInputUpdatesHandler]); + + const onControlGroupLoadHandler = useCallback((controlGroupContainer: ControlGroupContainer) => { + setControlGroup(controlGroupContainer); + }, []); + + const selectControlsWithPriority = useCallback(() => { + /* + * + * Below is the priority of how controls are fetched. + * 1. URL + * 2. If not found in URL, see in Localstorage + * 3. If not found in Localstorage, defaultControls are assigned + * + * */ + + const localInitialControls = cloneDeep(initialControls); + + let overridingControls = initialUrlParam; + if (!initialUrlParam && controlGroupInputUpdates) { + // if nothing is found in URL Param.. read from local storage + const urlParamsFromLocalStorage: FilterItemObj[] = Object.keys( + controlGroupInputUpdates?.panels + ).map((panelIdx) => { + const panel = controlGroupInputUpdates?.panels[panelIdx]; + + const { fieldName, title, selectedOptions, existsSelected, exclude } = + panel.explicitInput as OptionsListEmbeddableInput; + return { + fieldName, + title, + selectedOptions, + existsSelected, + exclude, + }; + }); + + overridingControls = urlParamsFromLocalStorage; + } + + // if initialUrlParam Exists... replace localInitialControls with what was provided in the Url + if (overridingControls && !urlDataApplied.current) { + let maxInitialControlIdx = localInitialControls.length - 1; + for (let counter = overridingControls.length - 1; counter >= 0; counter--) { + const urlControl = overridingControls[counter]; + const idx = localInitialControls.findIndex( + (item) => item.fieldName === urlControl.fieldName + ); + + if (idx !== -1) { + // if index found, replace that with what was provided in the Url + localInitialControls[idx] = { + ...localInitialControls[idx], + fieldName: urlControl.fieldName, + title: urlControl.title ?? urlControl.fieldName, + selectedOptions: urlControl.selectedOptions ?? [], + existsSelected: urlControl.existsSelected ?? false, + exclude: urlControl.exclude ?? false, + }; + } else { + // if url param is not available in initialControl, start replacing the last slot in the + // initial Control with the last `not found` element in the Url Param + // + localInitialControls[maxInitialControlIdx] = { + fieldName: urlControl.fieldName, + selectedOptions: urlControl.selectedOptions ?? [], + title: urlControl.title ?? urlControl.fieldName, + existsSelected: urlControl.existsSelected ?? false, + exclude: urlControl.exclude ?? false, + }; + maxInitialControlIdx--; + } + } + } + + return localInitialControls; + }, [initialUrlParam, initialControls, controlGroupInputUpdates]); + + const setOptions = useCallback( + async ( + defaultInput: Partial, + { addOptionsListControl }: ControlGroupBuilder + ) => { + const initialInput: Partial = { + ...defaultInput, + defaultControlWidth: 'small', + viewMode: ViewMode.VIEW, + timeRange, + filters, + query, + chainingSystem, + }; + + const finalControls = selectControlsWithPriority(); + + urlDataApplied.current = true; + + finalControls.forEach((control, idx) => { + addOptionsListControl(initialInput, { + controlId: String(idx), + hideExclude: true, + hideSort: true, + hidePanelTitles: true, + // option List controls will handle an invalid dataview + // & display an appropriate message + dataViewId: dataViewId ?? '', + ...control, + }); + }); + + return initialInput; + }, + [dataViewId, timeRange, filters, chainingSystem, query, selectControlsWithPriority] + ); + + useFilterUpdatesToUrlSync({ + controlGroupInput: controlGroupInputUpdates, + }); + + const withContextMenuAction = useCallback( + (fn: unknown) => { + return () => { + if (typeof fn === 'function') { + fn(); + } + toggleContextMenu(); + }; + }, + [toggleContextMenu] + ); + + const resetSelection = useCallback(() => { + if (!controlGroupInputUpdates) return; + + const { panels } = controlGroupInputUpdates; + Object.values(panels).forEach((control, idx) => { + controlGroup?.updateInputForChild(String(idx), { + ...control.explicitInput, + selectedOptions: initialControls[idx].selectedOptions ?? [], + existsSelected: false, + exclude: false, + title: initialControls[idx].title ?? initialControls[idx].fieldName, + fieldName: initialControls[idx].fieldName, + }); + }); + controlGroup?.reload(); + }, [controlGroupInputUpdates, controlGroup, initialControls]); + + const resetButton = useMemo( + () => ( + + {`Reset`} + + ), + [withContextMenuAction, resetSelection] + ); + + const contextMenuItems = useMemo(() => [resetButton], [resetButton]); + + return ( + + + + + {!controlGroup ? : null} + + + + } + isOpen={isContextMenuVisible} + closePopover={toggleContextMenu} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + + {props.children} + + ); +}; + +// FilterGroupNeeds spaceId to be invariant because it is being used in localstorage +// Hence we will render component only when spaceId has a value. +export const FilterGroup = withSpaceId( + FilterGroupComponent, + +); diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/loading.tsx b/x-pack/plugins/security_solution/public/common/components/filter_group/loading.tsx new file mode 100644 index 0000000000000..56da90f6d2c85 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/loading.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiLoadingChart } from '@elastic/eui'; + +export const FilterGroupLoading = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/filter_group/types.ts b/x-pack/plugins/security_solution/public/common/components/filter_group/types.ts new file mode 100644 index 0000000000000..e3f178ba24d66 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/filter_group/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ControlGroupInput, OptionsListEmbeddableInput } from '@kbn/controls-plugin/common'; +import type { AddOptionsListControlProps } from '@kbn/controls-plugin/public'; +import type { Filter } from '@kbn/es-query'; + +export type FilterUrlFormat = Record< + string, + Pick< + OptionsListEmbeddableInput, + 'selectedOptions' | 'title' | 'fieldName' | 'existsSelected' | 'exclude' + > +>; + +export interface FilterContextType { + allControls: FilterItemObj[] | undefined; + addControl: (controls: FilterItemObj) => void; +} + +export type FilterItemObj = Omit & + Pick; + +export type FilterGroupProps = { + dataViewId: string | null; + onFilterChange?: (newFilters: Filter[]) => void; + initialControls: FilterItemObj[]; + spaceId: string; +} & Pick; diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx index 8422b4dc96f28..2e496e5fa10d7 100644 --- a/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx @@ -30,7 +30,6 @@ export const useLocalStorage = ({ const readValueFromLocalStorage = useCallback(() => { const value = storage.get(`${plugin}.${key}`); - const valueAndDefaultTypesAreDifferent = typeof value !== typeof defaultValue; const valueIsInvalid = isInvalidDefault != null && isInvalidDefault(value); diff --git a/x-pack/plugins/security_solution/public/common/components/with_space_id/index.tsx b/x-pack/plugins/security_solution/public/common/components/with_space_id/index.tsx new file mode 100644 index 0000000000000..f7140f74d1d82 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/with_space_id/index.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiLoadingSpinner } from '@elastic/eui'; +import type { ComponentType } from 'react'; +import type { ReactElement } from 'react-markdown'; +import { useSpaceId } from '../../hooks/use_space_id'; + +type OmitSpaceId = Omit; + +interface WithSpaceIdArgs { + spaceId: string; +} + +/** + * + * This HOC ensures that component get valid non-null + * spaceId if the component needs it is as an invariant + * + * */ +export const withSpaceId =

( + Component: ComponentType & WithSpaceIdArgs>, + fallback?: ReactElement +) => { + const ComponentWithSpaceId = (props: OmitSpaceId

) => { + const spaceId = useSpaceId(); + + if (!spaceId) { + return ( + fallback ?? ( + + + + ) + ); + } + + return ; + }; + + return ComponentWithSpaceId; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts index fb7249f37adca..ff491d55c314a 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_url_state.ts @@ -30,5 +30,6 @@ export enum URL_PARAM_KEY { sourcerer = 'sourcerer', timeline = 'timeline', timerange = 'timerange', + pageFilter = 'pageFilters', rulesTable = 'rulesTable', } 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 5e4d4e5234ed2..407c2da04140e 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 @@ -78,7 +78,7 @@ export const AlertsTableComponent: React.FC = ({ showOnlyThreatIndicatorAlerts, tableId, to, - filterGroup = 'open', + filterGroup, }) => { const dispatch = useDispatch(); @@ -158,14 +158,17 @@ export const AlertsTableComponent: React.FC = ({ ); const defaultFiltersMemo = useMemo(() => { - const alertStatusFilter = buildAlertStatusFilter(filterGroup); - + let alertStatusFilter: Filter[] = []; + if (filterGroup) { + alertStatusFilter = buildAlertStatusFilter(filterGroup); + } if (isEmpty(defaultFilters)) { return alertStatusFilter; } else if (defaultFilters != null && !isEmpty(defaultFilters)) { return [...defaultFilters, ...alertStatusFilter]; } }, [defaultFilters, filterGroup]); + const { filterManager } = kibana.services.data.query; const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index c5ecfe797bc36..2aec4bb0e97e6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -124,12 +124,13 @@ const AlertContextMenuComponent: React.FC ); - }, [disabled, onButtonClick, ariaLabel]); + }, [disabled, onButtonClick, ariaLabel, isPopoverOpen]); const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => { newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); @@ -258,7 +259,7 @@ const AlertContextMenuComponent: React.FC - +

diff --git a/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx new file mode 100644 index 0000000000000..e4b1850025d0a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/detection_page_filters/index.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ComponentProps } from 'react'; +import React, { useState, useCallback } from 'react'; +import type { Filter } from '@kbn/es-query'; +import { isEqual } from 'lodash'; +import { DEFAULT_DETECTION_PAGE_FILTERS } from '../../../../common/constants'; +import { FilterGroup } from '../../../common/components/filter_group'; + +type FilterItemSetProps = Omit, 'initialControls'>; + +const FilterItemSetComponent = (props: FilterItemSetProps) => { + const { dataViewId, onFilterChange, ...restFilterItemGroupProps } = props; + + const [initialFilterControls] = useState(DEFAULT_DETECTION_PAGE_FILTERS); + + const filterChangesHandler = useCallback( + (newFilters: Filter[]) => { + if (!onFilterChange) { + return; + } + const updatedFilters = newFilters.map((filter) => { + return { + ...filter, + meta: { + ...filter.meta, + disabled: false, + }, + }; + }); + onFilterChange(updatedFilters); + }, + [onFilterChange] + ); + return ( + + ); +}; + +const arePropsEqual = (prevProps: FilterItemSetProps, newProps: FilterItemSetProps) => { + const _isEqual = isEqual(prevProps, newProps); + return _isEqual; +}; + +export const DetectionPageFilterSet = React.memo(FilterItemSetComponent, arePropsEqual); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx index 83df1b0018b1f..ddfe14d51201f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx @@ -7,7 +7,7 @@ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter, Query } from '@kbn/es-query'; -import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; @@ -143,7 +143,7 @@ const ChartPanelsComponent: React.FC = ({ {alertViewSelection === 'trend' && ( {isLoadingIndexPattern ? ( - + ) : ( = ({ {alertViewSelection === 'table' && ( {isLoadingIndexPattern ? ( - + ) : ( = ({ {alertViewSelection === 'treemap' && ( {isLoadingIndexPattern ? ( - + ) : ( = ({ - clearEventsDeleted, - clearEventsLoading, -}) => { +const DetectionEnginePageComponent: React.FC = () => { const dispatch = useDispatch(); const containerElement = useRef(null); const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); const graphEventId = useShallowEqualSelector( (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).graphEventId ); - const updatedAt = useShallowEqualSelector( - (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).updated - ); - const isAlertsLoading = useShallowEqualSelector( - (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).isLoading - ); const getGlobalFiltersQuerySelector = useMemo( () => inputsSelectors.globalFiltersQuerySelector(), [] @@ -128,9 +109,12 @@ const DetectionEnginePageComponent: React.FC = ({ const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = useListsConfig(); + const [detectionPageFilters, setDetectionPageFilters] = useState(); + const { indexPattern, runtimeMappings, + dataViewId, loading: isLoadingIndexPattern, } = useSourcererDataView(SourcererScopeName.detections); @@ -140,10 +124,8 @@ const DetectionEnginePageComponent: React.FC = ({ const loading = userInfoLoading || listsConfigLoading; const { application: { navigateToUrl }, - timelines: timelinesUi, data, } = useKibana().services; - const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); const { filterManager } = data.query; @@ -163,8 +145,6 @@ const DetectionEnginePageComponent: React.FC = ({ [filterManager] ); - const showUpdating = useMemo(() => isAlertsLoading || loading, [isAlertsLoading, loading]); - const updateDateRangeCallback = useCallback( ({ x }) => { if (!x) { @@ -190,25 +170,14 @@ const DetectionEnginePageComponent: React.FC = ({ [formatUrl, navigateToUrl] ); - // Callback for when open/closed filter changes - const onFilterGroupChangedCallback = useCallback( - (newFilterGroup: Status) => { - const timelineId = TableId.alertsOnAlertsPage; - clearEventsLoading({ id: timelineId }); - clearEventsDeleted({ id: timelineId }); - setFilterGroup(newFilterGroup); - }, - [clearEventsLoading, clearEventsDeleted, setFilterGroup] - ); - const alertsHistogramDefaultFilters = useMemo( () => [ ...filters, ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), - ...buildAlertStatusFilter(filterGroup), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), + ...(detectionPageFilters ?? []), ], - [filters, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filterGroup] + [filters, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, detectionPageFilters] ); // AlertsTable manages global filters itself, so not including `filters` @@ -216,8 +185,9 @@ const DetectionEnginePageComponent: React.FC = ({ () => [ ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), + ...(detectionPageFilters ?? []), ], - [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] + [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, detectionPageFilters] ); const onShowBuildingBlockAlertsChangedCallback = useCallback( @@ -258,6 +228,19 @@ const DetectionEnginePageComponent: React.FC = ({ [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] ); + const pageFiltersUpdateHandler = useCallback((newFilters: Filter[]) => { + setDetectionPageFilters(newFilters); + }, []); + + const isAlertTableLoading = useMemo( + () => loading || !Array.isArray(detectionPageFilters), + [loading, detectionPageFilters] + ); + const isChartPanelLoading = useMemo( + () => isLoadingIndexPattern || !Array.isArray(detectionPageFilters), + [isLoadingIndexPattern, detectionPageFilters] + ); + if (loading) { return ( @@ -324,33 +307,24 @@ const DetectionEnginePageComponent: React.FC = ({ {i18n.BUTTON_MANAGE_RULES} - - - - - - - - - - {updatedAt && - timelinesUi.getLastUpdated({ - updatedAt: updatedAt || Date.now(), - showUpdating, - })} - - - - - + + = ({ = ({ showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback} to={to} - filterGroup={filterGroup} /> diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index f508a920fa8bd..4ea871a5d8433 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -125,6 +125,8 @@ "@kbn/core-status-common-internal", "@kbn/repo-info", "@kbn/storybook", + "@kbn/controls-plugin", + "@kbn/shared-ux-utility", ], "exclude": [ "target/**/*", 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 18ab743d599d0..cb3e798e4a6e7 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5143,6 +5143,46 @@ } } } + }, + "accounts_stats": { + "type": "array", + "items": { + "properties": { + "account_id": { + "type": "keyword" + }, + "posture_score": { + "type": "long" + }, + "latest_findings_doc_count": { + "type": "long" + }, + "benchmark_id": { + "type": "keyword" + }, + "benchmark_name": { + "type": "keyword" + }, + "benchmark_version": { + "type": "keyword" + }, + "passed_findings_count": { + "type": "long" + }, + "failed_findings_count": { + "type": "long" + }, + "agents_count": { + "type": "short" + }, + "nodes_count": { + "type": "short" + }, + "pods_count": { + "type": "short" + } + } + } } } }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index a2bd70f068552..b5484f5a56e83 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10171,7 +10171,6 @@ "xpack.csp.cloudPosturePage.errorRenderer.errorDescription": "{error} {statusCode} : {body}", "xpack.csp.cloudPosturePage.packageNotInstalled.description": "Utilisez notre intégration {integrationFullName} (KSPM) pour mesurer votre configuration de cluster Kubernetes par rapport aux recommandations du CIS.", "xpack.csp.complianceDashboard.complianceByCisSection.complianceColumnTooltip": "{passed}/{total}", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsDescription": "L'intégration nécessite des droits d'accès supérieurs pour l'exécution de règles CIS Benchmarks. Vous pouvez suivre {link} pour générer les informations d'identification nécessaires.", "xpack.csp.dashboard.benchmarkSection.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.lastEvaluatedTitle": "Dernière évaluation {dateFromNow}", @@ -10210,18 +10209,6 @@ "xpack.csp.createPackagePolicy.customAssetsTab.dashboardViewLabel": "Afficher le tableau de bord CSP", "xpack.csp.createPackagePolicy.customAssetsTab.findingsViewLabel": "Afficher les résultats CSP ", "xpack.csp.createPackagePolicy.customAssetsTab.rulesViewLabel": "Afficher les règles CSP ", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.accessKeyIdFieldLabel": "ID de clé d'accès", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsInstructionsLink": "ces instructions", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsNote": "Si vous choisissez de ne pas fournir vos informations d'identification, seul un sous-ensemble des règles de benchmark sera évalué avec vos clusters.", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsTitle": "Informations d'identification AWS", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.optionalField": "Facultatif", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.roleARNFieldLabel": "Rôle ARN", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.secretAccessKeyFieldLabel": "Clé d'accès secrète", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sessionTokenFieldLabel": "Token de session", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialFileFieldLabel": "Nom de profil des informations d'identification", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialsFileFieldLabel": "Fichier d'informations d'identification partagé", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabel": "Déploiement Kubernetes", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabelTooltip": "Sélectionner votre type de déploiement Kubernetes", "xpack.csp.cspEvaluationBadge.failLabel": "Échec", "xpack.csp.cspEvaluationBadge.passLabel": "Réussite", "xpack.csp.cspSettings.rules": "Règles de sécurité du CSP - ", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fc998e22ac922..95ff0e1bcd852 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10160,7 +10160,6 @@ "xpack.csp.cloudPosturePage.errorRenderer.errorDescription": "{error} {statusCode}: {body}", "xpack.csp.cloudPosturePage.packageNotInstalled.description": "{integrationFullName}(KSPM)統合は、CISの推奨事項に照らしてKubernetesクラスター設定を測定します。", "xpack.csp.complianceDashboard.complianceByCisSection.complianceColumnTooltip": "{passed}/{total}", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsDescription": "この統合では、一部のCISベンチマークルールを実行するために昇格されたアクセス権が必要です。必要な資格情報を生成するには、{link}に従ってください。", "xpack.csp.dashboard.benchmarkSection.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.lastEvaluatedTitle": "前回の評価{dateFromNow}", @@ -10199,18 +10198,6 @@ "xpack.csp.createPackagePolicy.customAssetsTab.dashboardViewLabel": "CSPダッシュボードを表示", "xpack.csp.createPackagePolicy.customAssetsTab.findingsViewLabel": "CSP調査結果を表示 ", "xpack.csp.createPackagePolicy.customAssetsTab.rulesViewLabel": "CSPルールを表示 ", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.accessKeyIdFieldLabel": "アクセスキーID", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsInstructionsLink": "これらの手順", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsNote": "資格情報を指定しない場合は、ベンチマークルールのサブネットのみがクラスターに対して評価されます。", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsTitle": "AWS認証情報", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.optionalField": "オプション", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.roleARNFieldLabel": "ARNロール", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.secretAccessKeyFieldLabel": "シークレットアクセスキー", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sessionTokenFieldLabel": "セッショントークン", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialFileFieldLabel": "資格情報プロファイル名", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialsFileFieldLabel": "共有資格情報ファイル", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabel": "Kubernetesデプロイ", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabelTooltip": "Kubernetesデプロイタイプを選択", "xpack.csp.cspEvaluationBadge.failLabel": "失敗", "xpack.csp.cspEvaluationBadge.passLabel": "合格", "xpack.csp.cspSettings.rules": "CSPセキュリティルール - ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c063e7e87fe79..4b4d96d9e049d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10175,7 +10175,6 @@ "xpack.csp.cloudPosturePage.errorRenderer.errorDescription": "{error} {statusCode}:{body}", "xpack.csp.cloudPosturePage.packageNotInstalled.description": "使用我们的 {integrationFullName} (KSPM) 集成根据 CIS 建议衡量 Kubernetes 集群设置。", "xpack.csp.complianceDashboard.complianceByCisSection.complianceColumnTooltip": "{passed}/{total}", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsDescription": "该集成需要提升访问权限才能运行某些 CIS 基准规则。您可以访问 {link} 以生成必要的凭据。", "xpack.csp.dashboard.benchmarkSection.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterTitle": "{title} - {shortId}", "xpack.csp.dashboard.benchmarkSection.lastEvaluatedTitle": "上次评估于 {dateFromNow}", @@ -10214,18 +10213,6 @@ "xpack.csp.createPackagePolicy.customAssetsTab.dashboardViewLabel": "查看 CSP 仪表板", "xpack.csp.createPackagePolicy.customAssetsTab.findingsViewLabel": "查看 CSP 结果 ", "xpack.csp.createPackagePolicy.customAssetsTab.rulesViewLabel": "查看 CSP 规则 ", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.accessKeyIdFieldLabel": "访问密钥 ID", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsInstructionsLink": "以下说明", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsNote": "如果选择不提供凭据,将仅根据您的集群评估基准规则的子集。", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.awsCredentialsTitle": "AWS 凭据", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.optionalField": "可选", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.roleARNFieldLabel": "ARN 角色", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.secretAccessKeyFieldLabel": "机密访问密钥", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sessionTokenFieldLabel": "会话令牌", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialFileFieldLabel": "凭据配置文件名", - "xpack.csp.createPackagePolicy.eksIntegrationSettingsSection.sharedCredentialsFileFieldLabel": "共享凭据文件", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabel": "Kubernetes 部署", - "xpack.csp.createPackagePolicy.stepConfigure.integrationSettingsSection.kubernetesDeploymentLabelTooltip": "选择 Kubernetes 部署类型", "xpack.csp.cspEvaluationBadge.failLabel": "失败", "xpack.csp.cspEvaluationBadge.passLabel": "通过", "xpack.csp.cspSettings.rules": "CSP 安全规则 - ",