From fbe024306c37bd87d9c2d4a1bab45b89fb148e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Wed, 12 Apr 2023 17:54:21 +0200 Subject: [PATCH 01/12] [Security Solutions][Endpoint] Use id instead of identifier to get fleet artifact (#154810) ## Summary Get fleet artifacts by `artifact.id` instead of by `artifact.identifier` before pushing them to the endpoint manifest. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../artifacts/manifest_manager/manifest_manager.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index b6c1eeb49e9d5..8b213f26eab1c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -8,7 +8,7 @@ import pMap from 'p-map'; import semver from 'semver'; import type LRU from 'lru-cache'; -import { isEqual, isEmpty, keyBy } from 'lodash'; +import { isEqual, isEmpty } from 'lodash'; import { type Logger, type SavedObjectsClientContract } from '@kbn/core/server'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, @@ -365,11 +365,15 @@ export class ManifestManager { } if (fleetArtifacts) { - const fleetArtfactsByIdentifier = keyBy(fleetArtifacts, 'identifier'); + const fleetArtfactsByIdentifier: { [key: string]: InternalArtifactCompleteSchema } = {}; + fleetArtifacts.forEach((fleetArtifact) => { + fleetArtfactsByIdentifier[getArtifactId(fleetArtifact)] = fleetArtifact; + }); artifactsToCreate.forEach((artifact) => { - const fleetArtifact = fleetArtfactsByIdentifier[artifact.identifier]; - if (!fleetArtifact) return; const artifactId = getArtifactId(artifact); + const fleetArtifact = fleetArtfactsByIdentifier[artifactId]; + + if (!fleetArtifact) return; // Cache the compressed body of the artifact this.cache.set(artifactId, Buffer.from(artifact.body, 'base64')); newManifest.replaceArtifact(fleetArtifact); From c3e6e70428c9ee436bf00d23e020549cfaac9567 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 12 Apr 2023 17:58:50 +0200 Subject: [PATCH 02/12] [ML] Support multiple change point requests (#154237) --- .../change_point_detection_context.tsx | 82 ++--- .../change_point_detection_page.tsx | 290 +++++------------ .../change_point_type_filter.tsx | 13 +- .../change_points_table.tsx | 226 +++++++++++++ .../chart_component.tsx | 213 +------------ .../change_point_detection/charts_grid.tsx | 148 +++++++++ .../change_point_detection/constants.ts | 25 +- .../change_point_detection/fields_config.tsx | 301 ++++++++++++++++++ .../function_picker.tsx | 27 +- .../metric_field_selector.tsx | 2 + .../split_field_selector.tsx | 2 + .../use_change_point_agg_request.ts | 153 +++++---- .../use_common_chart_props.ts | 222 +++++++++++++ .../use_split_field_cardinality.ts | 6 +- .../public/hooks/use_cancellable_search.ts | 1 + 15 files changed, 1155 insertions(+), 556 deletions(-) create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/charts_grid.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx create mode 100644 x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx index 772e9c2794da0..427c6b505a8ef 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_context.tsx @@ -20,14 +20,13 @@ import type { Filter, Query } from '@kbn/es-query'; import { usePageUrlState } from '@kbn/ml-url-state'; import { useTimefilter, useTimeRangeUpdates } from '@kbn/ml-date-picker'; import { ES_FIELD_TYPES } from '@kbn/field-types'; -import { DEFAULT_AGG_FUNCTION } from './constants'; -import { useSplitFieldCardinality } from './use_split_field_cardinality'; +import { type QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; +import { type ChangePointType, DEFAULT_AGG_FUNCTION } from './constants'; import { createMergedEsQuery, getEsQueryFromSavedSearch, } from '../../application/utils/search_utils'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; -import { useChangePointResults } from './use_change_point_agg_request'; import { type TimeBuckets, TimeBucketsInterval } from '../../../common/time_buckets'; import { useDataSource } from '../../hooks/use_data_source'; import { useTimeBuckets } from '../../hooks/use_time_buckets'; @@ -37,10 +36,14 @@ export interface ChangePointDetectionPageUrlState { pageUrlState: ChangePointDetectionRequestParams; } -export interface ChangePointDetectionRequestParams { +export interface FieldConfig { fn: string; splitField?: string; metricField: string; +} + +export interface ChangePointDetectionRequestParams { + fieldConfigs: FieldConfig[]; interval: string; query: Query; filters: Filter[]; @@ -54,50 +57,29 @@ export const ChangePointDetectionContext = createContext<{ metricFieldOptions: DataViewField[]; splitFieldsOptions: DataViewField[]; updateRequestParams: (update: Partial) => void; - isLoading: boolean; - annotations: ChangePointAnnotation[]; resultFilters: Filter[]; updateFilters: (update: Filter[]) => void; resultQuery: Query; - progress: number; - pagination: { - activePage: number; - pageCount: number; - updatePagination: (newPage: number) => void; - }; - splitFieldCardinality: number | null; + combinedQuery: QueryDslQueryContainer; + selectedChangePoints: Record; + setSelectedChangePoints: (update: Record) => void; }>({ - isLoading: false, splitFieldsOptions: [], metricFieldOptions: [], requestParams: {} as ChangePointDetectionRequestParams, timeBuckets: {} as TimeBuckets, bucketInterval: {} as TimeBucketsInterval, updateRequestParams: () => {}, - annotations: [], resultFilters: [], updateFilters: () => {}, resultQuery: { query: '', language: 'kuery' }, - progress: 0, - pagination: { - activePage: 0, - pageCount: 1, - updatePagination: () => {}, - }, - splitFieldCardinality: null, + combinedQuery: {}, + selectedChangePoints: {}, + setSelectedChangePoints: () => {}, }); -export type ChangePointType = - | 'dip' - | 'spike' - | 'distribution_change' - | 'step_change' - | 'trend_change' - | 'stationary' - | 'non_stationary' - | 'indeterminable'; - export interface ChangePointAnnotation { + id: string; label: string; reason: string; timestamp: string; @@ -109,6 +91,8 @@ export interface ChangePointAnnotation { p_value: number; } +export type SelectedChangePoint = FieldConfig & ChangePointAnnotation; + export const ChangePointDetectionContextProvider: FC = ({ children }) => { const { dataView, savedSearch } = useDataSource(); const { @@ -129,8 +113,11 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { const timefilter = useTimefilter(); const timeBuckets = useTimeBuckets(); - const [resultFilters, setResultFilter] = useState([]); + const [resultFilters, setResultFilter] = useState([]); + const [selectedChangePoints, setSelectedChangePoints] = useState< + Record + >({}); const [bucketInterval, setBucketInterval] = useState(); const timeRange = useTimeRangeUpdates(); @@ -184,11 +171,13 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { const requestParams = useMemo(() => { const params = { ...requestParamsFromUrl }; - if (!params.fn) { - params.fn = DEFAULT_AGG_FUNCTION; - } - if (!params.metricField && metricFieldOptions.length > 0) { - params.metricField = metricFieldOptions[0].name; + if (!params.fieldConfigs) { + params.fieldConfigs = [ + { + fn: DEFAULT_AGG_FUNCTION, + metricField: metricFieldOptions[0]?.name, + }, + ]; } params.interval = bucketInterval?.expression!; return params; @@ -246,32 +235,21 @@ export const ChangePointDetectionContextProvider: FC = ({ children }) => { return mergedQuery; }, [resultFilters, resultQuery, uiSettings, dataView, timeRange]); - const splitFieldCardinality = useSplitFieldCardinality(requestParams.splitField, combinedQuery); - - const { - results: annotations, - isLoading: annotationsLoading, - progress, - pagination, - } = useChangePointResults(requestParams, combinedQuery, splitFieldCardinality); - if (!bucketInterval) return null; const value = { - isLoading: annotationsLoading, - progress, timeBuckets, requestParams, updateRequestParams, metricFieldOptions, splitFieldsOptions, - annotations, bucketInterval, resultFilters, updateFilters, resultQuery, - pagination, - splitFieldCardinality, + combinedQuery, + selectedChangePoints, + setSelectedChangePoints, }; return ( diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx index 3ea9d26ad67c4..98ab86190dd16 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx @@ -4,75 +4,46 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { FC, useCallback } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { - EuiBadge, + EuiButtonEmpty, EuiCallOut, - EuiDescriptionList, - EuiEmptyPrompt, - EuiFlexGrid, EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, - EuiIcon, - EuiPagination, - EuiPanel, - EuiProgress, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, EuiSpacer, EuiText, + EuiTitle, EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Query } from '@kbn/es-query'; +import { ChartsGrid } from './charts_grid'; +import { FieldsConfig } from './fields_config'; import { useDataSource } from '../../hooks/use_data_source'; -import { SPLIT_FIELD_CARDINALITY_LIMIT } from './constants'; import { ChangePointTypeFilter } from './change_point_type_filter'; import { SearchBarWrapper } from './search_bar'; -import { ChangePointType, useChangePointDetectionContext } from './change_point_detection_context'; -import { MetricFieldSelector } from './metric_field_selector'; -import { SplitFieldSelector } from './split_field_selector'; -import { FunctionPicker } from './function_picker'; -import { ChartComponent } from './chart_component'; +import { useChangePointDetectionContext } from './change_point_detection_context'; +import { type ChangePointType } from './constants'; export const ChangePointDetectionPage: FC = () => { + const [isFlyoutVisible, setFlyoutVisible] = useState(false); + const { requestParams, updateRequestParams, - annotations, resultFilters, updateFilters, resultQuery, - progress, - pagination, - splitFieldCardinality, - splitFieldsOptions, metricFieldOptions, + selectedChangePoints, } = useChangePointDetectionContext(); const { dataView } = useDataSource(); - const setFn = useCallback( - (fn: string) => { - updateRequestParams({ fn }); - }, - [updateRequestParams] - ); - - const setSplitField = useCallback( - (splitField: string | undefined) => { - updateRequestParams({ splitField }); - }, - [updateRequestParams] - ); - - const setMetricField = useCallback( - (metricField: string) => { - updateRequestParams({ metricField }); - }, - [updateRequestParams] - ); - const setQuery = useCallback( (query: Query) => { updateRequestParams({ query }); @@ -87,11 +58,6 @@ export const ChangePointDetectionPage: FC = () => { [updateRequestParams] ); - const selectControlCss = { width: '300px' }; - - const cardinalityExceeded = - splitFieldCardinality && splitFieldCardinality > SPLIT_FIELD_CARDINALITY_LIMIT; - if (metricFieldOptions.length === 0) { return ( { ); } + const hasSelectedChangePoints = Object.values(selectedChangePoints).some((v) => v.length > 0); + return (
{ - - - - - - - - {splitFieldsOptions.length > 0 ? ( - - - - ) : null} - - - - } - value={progress} - max={100} - valueText - size="m" - /> - - - - - - - {cardinalityExceeded ? ( - <> - -

- {i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningMessage', { - defaultMessage: - 'The "{splitField}" field cardinality is {cardinality} which exceeds the limit of {cardinalityLimit}. Only the first {cardinalityLimit} partitions, sorted by document count, are analyzed.', - values: { - cardinality: splitFieldCardinality, - cardinalityLimit: SPLIT_FIELD_CARDINALITY_LIMIT, - splitField: requestParams.splitField, - }, - })} -

-
- - - ) : null} - - - - {requestParams.interval} - + + + + + {requestParams.interval} + + + + + ) + } + > + setFlyoutVisible(!isFlyoutVisible)} + size={'s'} + disabled={!hasSelectedChangePoints} + > + + + + + { - {annotations.length === 0 && progress === 100 ? ( - <> - + + + {isFlyoutVisible ? ( + + + +

- } - body={ -

- -

- } - /> - - ) : null} - - = 2 ? 2 : 1} responsive gutterSize={'m'}> - {annotations.map((v) => { - return ( - - - - - {v.group ? ( - - ) : null} - - {v.reason ? ( - - - - ) : null} - - - - {requestParams.fn}({requestParams.metricField}) - - - - - - - - {v.p_value !== undefined ? ( - - - ), - description: v.p_value.toPrecision(3), - }, - ]} - /> - - ) : null} - - {v.type} - - - - - - - ); - })} - - - - - {pagination.pageCount > 1 ? ( - - - - - +
+
+ + + +
) : null}
); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx index 50441710c33ef..c73764887aafc 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_type_filter.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isDefined } from '@kbn/ml-is-defined'; -import { type ChangePointType } from './change_point_detection_context'; +import { type ChangePointType, CHANGE_POINT_TYPES } from './constants'; export type ChangePointUIValue = ChangePointType | undefined; @@ -29,32 +29,32 @@ interface ChangePointTypeFilterProps { const changePointTypes: Array<{ value: ChangePointType; description: string }> = [ { - value: 'dip', + value: CHANGE_POINT_TYPES.DIP, description: i18n.translate('xpack.aiops.changePointDetection.dipDescription', { defaultMessage: 'A significant dip occurs at this point.', }), }, { - value: 'spike', + value: CHANGE_POINT_TYPES.SPIKE, description: i18n.translate('xpack.aiops.changePointDetection.spikeDescription', { defaultMessage: 'A significant spike occurs at this point.', }), }, { - value: 'distribution_change', + value: CHANGE_POINT_TYPES.DISTRIBUTION_CHANGE, description: i18n.translate('xpack.aiops.changePointDetection.distributionChangeDescription', { defaultMessage: 'The overall distribution of the values has changed significantly.', }), }, { - value: 'step_change', + value: CHANGE_POINT_TYPES.STEP_CHANGE, description: i18n.translate('xpack.aiops.changePointDetection.stepChangeDescription', { defaultMessage: 'The change indicates a statistically significant step up or down in value distribution.', }), }, { - value: 'trend_change', + value: CHANGE_POINT_TYPES.TREND_CHANGE, description: i18n.translate('xpack.aiops.changePointDetection.trendChangeDescription', { defaultMessage: 'An overall trend change occurs at this point.', }), @@ -134,6 +134,7 @@ export const ChangePointTypeFilter: FC = ({ value, o isClearable data-test-subj="aiopsChangePointTypeFilter" renderOption={renderOption} + compressed /> ); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx new file mode 100644 index 0000000000000..28ff26ad7645a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx @@ -0,0 +1,226 @@ +/* + * 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 { + EuiBadge, + type EuiBasicTableColumn, + EuiEmptyPrompt, + EuiIcon, + EuiInMemoryTable, + EuiToolTip, +} from '@elastic/eui'; +import React, { type FC, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; +import { useCommonChartProps } from './use_common_chart_props'; +import { + type ChangePointAnnotation, + FieldConfig, + SelectedChangePoint, +} from './change_point_detection_context'; +import { type ChartComponentProps } from './chart_component'; +import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; + +export interface ChangePointsTableProps { + annotations: ChangePointAnnotation[]; + fieldConfig: FieldConfig; + isLoading: boolean; + onSelectionChange: (update: SelectedChangePoint[]) => void; +} + +export const ChangePointsTable: FC = ({ + isLoading, + annotations, + fieldConfig, + onSelectionChange, +}) => { + const { fieldFormats } = useAiopsAppContext(); + + const dateFormatter = useMemo(() => fieldFormats.deserialize({ id: 'date' }), [fieldFormats]); + + const defaultSorting = { + sort: { + field: 'p_value', + // Lower p_value indicates a bigger change point, hence the asc sorting + direction: 'asc' as const, + }, + }; + + const columns: Array> = [ + { + field: 'timestamp', + name: i18n.translate('xpack.aiops.changePointDetection.timeColumn', { + defaultMessage: 'Time', + }), + sortable: true, + truncateText: false, + width: '230px', + render: (timestamp: ChangePointAnnotation['timestamp']) => dateFormatter.convert(timestamp), + }, + { + name: i18n.translate('xpack.aiops.changePointDetection.previewColumn', { + defaultMessage: 'Preview', + }), + align: 'center', + width: '200px', + height: '80px', + truncateText: false, + valign: 'middle', + css: { display: 'block', padding: 0 }, + render: (annotation: ChangePointAnnotation) => { + return ; + }, + }, + { + field: 'type', + name: i18n.translate('xpack.aiops.changePointDetection.typeColumn', { + defaultMessage: 'Type', + }), + sortable: true, + truncateText: false, + render: (type: ChangePointAnnotation['type']) => {type}, + }, + { + field: 'p_value', + name: ( + + + {i18n.translate( + 'xpack.aiops.explainLogRateSpikes.spikeAnalysisTableGroups.pValueLabel', + { + defaultMessage: 'p-value', + } + )} + + + + ), + sortable: true, + truncateText: false, + render: (pValue: ChangePointAnnotation['p_value']) => pValue.toPrecision(3), + }, + ...(fieldConfig.splitField + ? [ + { + field: 'group.name', + name: i18n.translate('xpack.aiops.changePointDetection.fieldNameColumn', { + defaultMessage: 'Field name', + }), + truncateText: false, + }, + { + field: 'group.value', + name: i18n.translate('xpack.aiops.changePointDetection.fieldValueColumn', { + defaultMessage: 'Field value', + }), + truncateText: false, + sortable: true, + }, + ] + : []), + ]; + + const selectionValue = useMemo>(() => { + return { + selectable: (item) => true, + onSelectionChange: (selection) => { + onSelectionChange( + selection.map((s) => { + return { + ...s, + ...fieldConfig, + }; + }) + ); + }, + }; + }, [fieldConfig, onSelectionChange]); + + return ( + + itemId={'id'} + selection={selectionValue} + loading={isLoading} + items={annotations} + columns={columns} + pagination={{ pageSizeOptions: [5, 10, 15] }} + sorting={defaultSorting} + message={ + isLoading ? ( + + + + } + /> + ) : ( + + + + } + body={ +

+ +

+ } + /> + ) + } + /> + ); +}; + +export const MiniChartPreview: FC = ({ fieldConfig, annotation }) => { + const { + lens: { EmbeddableComponent }, + } = useAiopsAppContext(); + + const { filters, query, attributes, timeRange } = useCommonChartProps({ + annotation, + fieldConfig, + previewMode: true, + }); + + return ( +
+ +
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx index e22249b9fa8ac..0866fe325b40a 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/chart_component.tsx @@ -5,230 +5,41 @@ * 2.0. */ -import React, { FC, useMemo } from 'react'; - -import { type TypedLensByValueInput } from '@kbn/lens-plugin/public'; -import { FilterStateStore } from '@kbn/es-query'; -import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; - -import { useDataSource } from '../../hooks/use_data_source'; +import React, { FC } from 'react'; +import { useCommonChartProps } from './use_common_chart_props'; import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; - -import { - type ChangePointAnnotation, - useChangePointDetectionContext, -} from './change_point_detection_context'; -import { fnOperationTypeMapping } from './constants'; +import type { ChangePointAnnotation, FieldConfig } from './change_point_detection_context'; export interface ChartComponentProps { + fieldConfig: FieldConfig; annotation: ChangePointAnnotation; } -export const ChartComponent: FC = React.memo(({ annotation }) => { +export const ChartComponent: FC = React.memo(({ annotation, fieldConfig }) => { const { lens: { EmbeddableComponent }, } = useAiopsAppContext(); - const timeRange = useTimeRangeUpdates(); - const { dataView } = useDataSource(); - const { requestParams, bucketInterval, resultQuery, resultFilters } = - useChangePointDetectionContext(); - - const filters = useMemo(() => { - return [ - ...resultFilters, - ...(annotation.group - ? [ - { - meta: { - index: dataView.id!, - alias: null, - negate: false, - disabled: false, - type: 'phrase', - key: annotation.group.name, - params: { - query: annotation.group.value, - }, - }, - query: { - match_phrase: { - [annotation.group.name]: annotation.group.value, - }, - }, - $state: { - store: FilterStateStore.APP_STATE, - }, - }, - ] - : []), - ]; - }, [dataView.id, annotation.group, resultFilters]); - - // @ts-ignore incorrect types for attributes - const attributes = useMemo(() => { - return { - title: annotation.group?.value ?? '', - description: '', - visualizationType: 'lnsXY', - type: 'lens', - references: [ - { - type: 'index-pattern', - id: dataView.id!, - name: 'indexpattern-datasource-layer-2d61a885-abb0-4d4e-a5f9-c488caec3c22', - }, - { - type: 'index-pattern', - id: dataView.id!, - name: 'xy-visualization-layer-8d26ab67-b841-4877-9d02-55bf270f9caf', - }, - ], - state: { - visualization: { - yLeftExtent: { - mode: 'dataBounds', - }, - legend: { - isVisible: false, - position: 'right', - }, - valueLabels: 'hide', - fittingFunction: 'None', - axisTitlesVisibilitySettings: { - x: true, - yLeft: true, - yRight: true, - }, - tickLabelsVisibilitySettings: { - x: true, - yLeft: true, - yRight: true, - }, - labelsOrientation: { - x: 0, - yLeft: 0, - yRight: 0, - }, - gridlinesVisibilitySettings: { - x: true, - yLeft: true, - yRight: true, - }, - preferredSeriesType: 'line', - layers: [ - { - layerId: '2d61a885-abb0-4d4e-a5f9-c488caec3c22', - accessors: ['e9f26d17-fb36-4982-8539-03f1849cbed0'], - position: 'top', - seriesType: 'line', - showGridlines: false, - layerType: 'data', - xAccessor: '877e6638-bfaa-43ec-afb9-2241dc8e1c86', - }, - ...(annotation.timestamp - ? [ - { - layerId: '8d26ab67-b841-4877-9d02-55bf270f9caf', - layerType: 'annotations', - annotations: [ - { - type: 'manual', - label: annotation.label, - icon: 'triangle', - textVisibility: true, - key: { - type: 'point_in_time', - timestamp: annotation.timestamp, - }, - id: 'a8fb297c-8d96-4011-93c0-45af110d5302', - isHidden: false, - color: '#F04E98', - lineStyle: 'solid', - lineWidth: 2, - outside: false, - }, - ], - ignoreGlobalFilters: true, - }, - ] - : []), - ], - }, - query: resultQuery, - filters, - datasourceStates: { - formBased: { - layers: { - '2d61a885-abb0-4d4e-a5f9-c488caec3c22': { - columns: { - '877e6638-bfaa-43ec-afb9-2241dc8e1c86': { - label: dataView.timeFieldName, - dataType: 'date', - operationType: 'date_histogram', - sourceField: dataView.timeFieldName, - isBucketed: true, - scale: 'interval', - params: { - interval: bucketInterval.expression, - includeEmptyRows: true, - dropPartials: false, - }, - }, - 'e9f26d17-fb36-4982-8539-03f1849cbed0': { - label: `${requestParams.fn}(${requestParams.metricField})`, - dataType: 'number', - operationType: fnOperationTypeMapping[requestParams.fn], - sourceField: requestParams.metricField, - isBucketed: false, - scale: 'ratio', - params: { - emptyAsNull: true, - }, - }, - }, - columnOrder: [ - '877e6638-bfaa-43ec-afb9-2241dc8e1c86', - 'e9f26d17-fb36-4982-8539-03f1849cbed0', - ], - incompleteColumns: {}, - }, - }, - }, - textBased: { - layers: {}, - }, - }, - internalReferences: [], - adHocDataViews: {}, - }, - }; - }, [ - annotation.group?.value, - annotation.timestamp, - annotation.label, - dataView.id, - dataView.timeFieldName, - resultQuery, - filters, - bucketInterval.expression, - requestParams.fn, - requestParams.metricField, - ]); + const { filters, timeRange, query, attributes } = useCommonChartProps({ + fieldConfig, + annotation, + }); return ( ); }); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/charts_grid.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/charts_grid.tsx new file mode 100644 index 0000000000000..2a1cb2cf26d89 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/charts_grid.tsx @@ -0,0 +1,148 @@ +/* + * 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, { type FC, useMemo, useState } from 'react'; +import { + EuiBadge, + EuiDescriptionList, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiPagination, + EuiPanel, + EuiText, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { SelectedChangePoint } from './change_point_detection_context'; +import { ChartComponent } from './chart_component'; + +const CHARTS_PER_PAGE = 6; + +interface ChartsGridProps { + changePoints: Record; +} + +export const ChartsGrid: FC = ({ changePoints: changePointsDict }) => { + const changePoints = useMemo(() => { + return Object.values(changePointsDict).flat(); + }, [changePointsDict]); + + const [activePage, setActivePage] = useState(0); + + const resultPerPage = useMemo(() => { + const start = activePage * CHARTS_PER_PAGE; + return changePoints.slice(start, start + CHARTS_PER_PAGE); + }, [changePoints, activePage]); + + const pagination = useMemo(() => { + return { + activePage, + pageCount: Math.ceil((changePoints.length ?? 0) / CHARTS_PER_PAGE), + updatePagination: setActivePage, + }; + }, [activePage, changePoints.length]); + + return ( + <> + = 2 ? 2 : 1} responsive gutterSize={'m'}> + {resultPerPage.map((v, index) => { + const key = `${index}_${v.group?.value ?? 'single_metric'}_${v.fn}_${v.metricField}_${ + v.timestamp + }_${v.p_value}`; + return ( + + + + + {v.group ? ( + + ) : null} + + {v.reason ? ( + + + + ) : null} + + + + {v.fn}({v.metricField}) + + + + + + + + {v.p_value !== undefined ? ( + + + ), + description: v.p_value.toPrecision(3), + }, + ]} + /> + + ) : null} + + {v.type} + + + + + + + ); + })} + + + {pagination.pageCount > 1 ? ( + + + + + + ) : null} + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts b/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts index 06141db37b060..0219b3ac87fc0 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/constants.ts @@ -6,10 +6,10 @@ */ export const fnOperationTypeMapping: Record = { - min: 'min', + avg: 'average', max: 'max', + min: 'min', sum: 'sum', - avg: 'average', } as const; export const DEFAULT_AGG_FUNCTION = 'avg'; @@ -17,3 +17,24 @@ export const DEFAULT_AGG_FUNCTION = 'avg'; export const SPLIT_FIELD_CARDINALITY_LIMIT = 10000; export const COMPOSITE_AGG_SIZE = 500; + +export const CHANGE_POINT_TYPES = { + DIP: 'dip', + SPIKE: 'spike', + DISTRIBUTION_CHANGE: 'distribution_change', + STEP_CHANGE: 'step_change', + TREND_CHANGE: 'trend_change', + STATIONARY: 'stationary', + NON_STATIONARY: 'non_stationary', + INDETERMINABLE: 'indeterminable', +} as const; + +export type ChangePointType = typeof CHANGE_POINT_TYPES[keyof typeof CHANGE_POINT_TYPES]; + +export const EXCLUDED_CHANGE_POINT_TYPES = new Set([ + CHANGE_POINT_TYPES.STATIONARY, + CHANGE_POINT_TYPES.NON_STATIONARY, + CHANGE_POINT_TYPES.INDETERMINABLE, +]); + +export const MAX_CHANGE_POINT_CONFIGS = 6; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx new file mode 100644 index 0000000000000..95aa96f393601 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx @@ -0,0 +1,301 @@ +/* + * 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, { type FC, useCallback } from 'react'; +import { + EuiAccordion, + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiProgress, + EuiSpacer, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { ChangePointsTable } from './change_points_table'; +import { MAX_CHANGE_POINT_CONFIGS, SPLIT_FIELD_CARDINALITY_LIMIT } from './constants'; +import { FunctionPicker } from './function_picker'; +import { MetricFieldSelector } from './metric_field_selector'; +import { SplitFieldSelector } from './split_field_selector'; +import { + type ChangePointAnnotation, + type FieldConfig, + SelectedChangePoint, + useChangePointDetectionContext, +} from './change_point_detection_context'; +import { useChangePointResults } from './use_change_point_agg_request'; +import { useSplitFieldCardinality } from './use_split_field_cardinality'; + +const selectControlCss = { width: '300px' }; + +/** + * Contains panels with controls and change point results. + */ +export const FieldsConfig: FC = () => { + const { + requestParams: { fieldConfigs }, + updateRequestParams, + selectedChangePoints, + setSelectedChangePoints, + } = useChangePointDetectionContext(); + + const onChange = useCallback( + (update: FieldConfig, index: number) => { + fieldConfigs.splice(index, 1, update); + updateRequestParams({ fieldConfigs }); + }, + [updateRequestParams, fieldConfigs] + ); + + const onAdd = useCallback(() => { + const update = [...fieldConfigs]; + update.push(update[update.length - 1]); + updateRequestParams({ fieldConfigs: update }); + }, [updateRequestParams, fieldConfigs]); + + const onRemove = useCallback( + (index: number) => { + fieldConfigs.splice(index, 1); + updateRequestParams({ fieldConfigs }); + + delete selectedChangePoints[index]; + setSelectedChangePoints({ + ...selectedChangePoints, + }); + }, + [updateRequestParams, fieldConfigs, setSelectedChangePoints, selectedChangePoints] + ); + + const onSelectionChange = useCallback( + (update: SelectedChangePoint[], index: number) => { + setSelectedChangePoints({ + ...selectedChangePoints, + [index]: update, + }); + }, + [setSelectedChangePoints, selectedChangePoints] + ); + + return ( + <> + {fieldConfigs.map((fieldConfig, index) => { + const key = index; + return ( + + onChange(value, index)} + onRemove={onRemove.bind(null, index)} + removeDisabled={fieldConfigs.length === 1} + onSelectionChange={(update) => { + onSelectionChange(update, index); + }} + /> + + + ); + })} + = MAX_CHANGE_POINT_CONFIGS}> + + + + ); +}; + +export interface FieldPanelProps { + fieldConfig: FieldConfig; + removeDisabled: boolean; + onChange: (update: FieldConfig) => void; + onRemove: () => void; + onSelectionChange: (update: SelectedChangePoint[]) => void; +} + +/** + * Components that combines field config and state for change point response. + * @param fieldConfig + * @param onChange + * @param onRemove + * @param removeDisabled + * @constructor + */ +const FieldPanel: FC = ({ + fieldConfig, + onChange, + onRemove, + removeDisabled, + onSelectionChange, +}) => { + const { combinedQuery, requestParams } = useChangePointDetectionContext(); + + const splitFieldCardinality = useSplitFieldCardinality(fieldConfig.splitField, combinedQuery); + + const { + results: annotations, + isLoading: annotationsLoading, + progress, + } = useChangePointResults(fieldConfig, requestParams, combinedQuery, splitFieldCardinality); + + const accordionId = useGeneratedHtmlId({ prefix: 'fieldConfig' }); + + return ( + + + + + } + value={progress ?? 0} + max={100} + valueText + size="m" + /> + + + + } + extraAction={ + + } + paddingSize="s" + > + + + + ); +}; + +interface FieldsControlsProps { + fieldConfig: FieldConfig; + onChange: (update: FieldConfig) => void; +} + +/** + * Renders controls for fields selection and emits updates on change. + */ +export const FieldsControls: FC = ({ fieldConfig, onChange, children }) => { + const { splitFieldsOptions } = useChangePointDetectionContext(); + + const onChangeFn = useCallback( + (field: keyof FieldConfig, value: string) => { + const result = { ...fieldConfig, [field]: value }; + onChange(result); + }, + [onChange, fieldConfig] + ); + + return ( + + + onChangeFn('fn', v)} /> + + + onChangeFn('metricField', v)} + /> + + {splitFieldsOptions.length > 0 ? ( + + onChangeFn('splitField', v!)} + /> + + ) : null} + + {children} + + ); +}; + +interface ChangePointResultsProps { + fieldConfig: FieldConfig; + splitFieldCardinality: number | null; + isLoading: boolean; + annotations: ChangePointAnnotation[]; + onSelectionChange: (update: SelectedChangePoint[]) => void; +} + +/** + * Handles request and rendering results of the change point with provided config. + */ +export const ChangePointResults: FC = ({ + fieldConfig, + splitFieldCardinality, + isLoading, + annotations, + onSelectionChange, +}) => { + const cardinalityExceeded = + splitFieldCardinality && splitFieldCardinality > SPLIT_FIELD_CARDINALITY_LIMIT; + + return ( + <> + + + {cardinalityExceeded ? ( + <> + +

+ {i18n.translate('xpack.aiops.changePointDetection.cardinalityWarningMessage', { + defaultMessage: + 'The "{splitField}" field cardinality is {cardinality} which exceeds the limit of {cardinalityLimit}. Only the first {cardinalityLimit} partitions, sorted by document count, are analyzed.', + values: { + cardinality: splitFieldCardinality, + cardinalityLimit: SPLIT_FIELD_CARDINALITY_LIMIT, + splitField: fieldConfig.splitField, + }, + })} +

+
+ + + ) : null} + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx index 52a304d85fb85..2e86961b80432 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/function_picker.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { EuiButtonGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { FC } from 'react'; import { fnOperationTypeMapping } from './constants'; @@ -18,21 +18,22 @@ interface FunctionPickerProps { export const FunctionPicker: FC = React.memo(({ value, onChange }) => { const options = Object.keys(fnOperationTypeMapping).map((v) => { return { - value: v, - text: v, + id: v, + label: v, }; }); return ( - - onChange(e.target.value)} - prepend={i18n.translate('xpack.aiops.changePointDetection.selectFunctionLabel', { - defaultMessage: 'Function', - })} - /> - + onChange(id)} + isFullWidth + buttonSize="compressed" + onClick={(e) => e.stopPropagation()} + /> ); }); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx index 539df3a13608d..266e69b8d6f11 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/metric_field_selector.tsx @@ -38,6 +38,7 @@ export const MetricFieldSelector: FC = React.memo( return ( = React.memo( onChange={onChangeCallback} isClearable={false} data-test-subj="aiopsChangePointMetricField" + onClick={(e) => e.stopPropagation()} /> ); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx index fbe5478054882..cc52a0cc5ee37 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/split_field_selector.tsx @@ -47,6 +47,7 @@ export const SplitFieldSelector: FC = React.memo(({ val return ( = React.memo(({ val onChange={onChangeCallback} isClearable data-test-subj="aiopsChangePointSplitField" + onClick={(e) => e.stopPropagation()} /> ); diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts index 35d2f768fc45b..cede70de9e11b 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useEffect, useCallback, useState, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { type QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; import { i18n } from '@kbn/i18n'; import { useRefresh } from '@kbn/ml-date-picker'; @@ -14,11 +14,16 @@ import { useAiopsAppContext } from '../../hooks/use_aiops_app_context'; import { ChangePointAnnotation, ChangePointDetectionRequestParams, - ChangePointType, + FieldConfig, } from './change_point_detection_context'; import { useDataSource } from '../../hooks/use_data_source'; import { useCancellableSearch } from '../../hooks/use_cancellable_search'; -import { SPLIT_FIELD_CARDINALITY_LIMIT, COMPOSITE_AGG_SIZE } from './constants'; +import { + type ChangePointType, + COMPOSITE_AGG_SIZE, + EXCLUDED_CHANGE_POINT_TYPES, + SPLIT_FIELD_CARDINALITY_LIMIT, +} from './constants'; interface RequestOptions { index: string; @@ -104,9 +109,8 @@ function getChangePointDetectionRequestBody( }; } -const CHARTS_PER_PAGE = 6; - export function useChangePointResults( + fieldConfig: FieldConfig, requestParams: ChangePointDetectionRequestParams, query: QueryDslQueryContainer, splitFieldCardinality: number | null @@ -120,10 +124,12 @@ export function useChangePointResults( const refresh = useRefresh(); const [results, setResults] = useState([]); - const [activePage, setActivePage] = useState(0); - const [progress, setProgress] = useState(0); + /** + * null also means the fetching has been complete + */ + const [progress, setProgress] = useState(null); - const isSingleMetric = !isDefined(requestParams.splitField); + const isSingleMetric = !isDefined(fieldConfig.splitField); const totalAggPages = useMemo(() => { return Math.ceil( @@ -131,12 +137,10 @@ export function useChangePointResults( ); }, [splitFieldCardinality]); - const { runRequest, cancelRequest, isLoading } = useCancellableSearch(); + const { runRequest, cancelRequest } = useCancellableSearch(); const reset = useCallback(() => { cancelRequest(); - setProgress(0); - setActivePage(0); setResults([]); }, [cancelRequest]); @@ -144,18 +148,18 @@ export function useChangePointResults( async (pageNumber: number = 1, afterKey?: string) => { try { if (!isSingleMetric && !totalAggPages) { - setProgress(100); + setProgress(null); return; } const requestPayload = getChangePointDetectionRequestBody( { index: dataView.getIndexPattern(), - fn: requestParams.fn, + fn: fieldConfig.fn, timeInterval: requestParams.interval, - metricField: requestParams.metricField, + metricField: fieldConfig.metricField, timeField: dataView.timeFieldName!, - splitField: requestParams.splitField, + splitField: fieldConfig.splitField, afterKey, }, query @@ -166,63 +170,68 @@ export function useChangePointResults( >(requestPayload); if (result === null) { - setProgress(100); + setProgress(null); return; } + const isFetchCompleted = !( + result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm && + pageNumber < totalAggPages + ); + const buckets = ( isSingleMetric ? [result.rawResponse.aggregations] : result.rawResponse.aggregations.groupings.buckets ) as ChangePointAggResponse['aggregations']['groupings']['buckets']; - setProgress(Math.min(Math.round((pageNumber / totalAggPages) * 100), 100)); + setProgress( + isFetchCompleted ? null : Math.min(Math.round((pageNumber / totalAggPages) * 100), 100) + ); - let groups = buckets.map((v) => { - const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType; - const timeAsString = v.change_point_request.bucket?.key; - const rawPValue = v.change_point_request.type[changePointType].p_value; + let groups = buckets + .map((v) => { + const changePointType = Object.keys(v.change_point_request.type)[0] as ChangePointType; + const timeAsString = v.change_point_request.bucket?.key; + const rawPValue = v.change_point_request.type[changePointType].p_value; - return { - ...(isSingleMetric - ? {} - : { - group: { - name: requestParams.splitField, - value: v.key.splitFieldTerm, - }, - }), - type: changePointType, - p_value: rawPValue, - timestamp: timeAsString, - label: changePointType, - reason: v.change_point_request.type[changePointType].reason, - } as ChangePointAnnotation; - }); + return { + ...(isSingleMetric + ? {} + : { + group: { + name: fieldConfig.splitField, + value: v.key.splitFieldTerm, + }, + }), + type: changePointType, + p_value: rawPValue, + timestamp: timeAsString, + label: changePointType, + reason: v.change_point_request.type[changePointType].reason, + id: isSingleMetric + ? 'single_metric' + : `${fieldConfig.splitField}_${v.key?.splitFieldTerm}`, + } as ChangePointAnnotation; + }) + .filter((v) => !EXCLUDED_CHANGE_POINT_TYPES.has(v.type)); if (Array.isArray(requestParams.changePointType)) { groups = groups.filter((v) => requestParams.changePointType!.includes(v.type)); } setResults((prev) => { - return ( - (prev ?? []) - .concat(groups) - // Lower p_value indicates a bigger change point, hence the acs sorting - .sort((a, b) => a.p_value - b.p_value) - ); + return (prev ?? []).concat(groups); }); if ( - result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm && - pageNumber < totalAggPages + !isFetchCompleted && + result.rawResponse.aggregations?.groupings?.after_key?.splitFieldTerm ) { await fetchResults( pageNumber + 1, result.rawResponse.aggregations.groupings.after_key.splitFieldTerm ); - } else { - setProgress(100); } } catch (e) { toasts.addError(e, { @@ -232,35 +241,53 @@ export function useChangePointResults( }); } }, - [runRequest, requestParams, query, dataView, totalAggPages, toasts, isSingleMetric] + [ + runRequest, + requestParams.interval, + requestParams.changePointType, + fieldConfig.fn, + fieldConfig.metricField, + fieldConfig.splitField, + query, + dataView, + totalAggPages, + toasts, + isSingleMetric, + ] ); useEffect( function fetchResultsOnInputChange() { + setProgress(0); reset(); + + if (fieldConfig.splitField && splitFieldCardinality === null) { + // wait for cardinality to be resolved + return; + } + fetchResults(); return () => { cancelRequest(); }; }, - [requestParams, query, splitFieldCardinality, fetchResults, reset, cancelRequest, refresh] + [ + requestParams.interval, + requestParams.changePointType, + fieldConfig.fn, + fieldConfig.metricField, + fieldConfig.splitField, + query, + splitFieldCardinality, + fetchResults, + reset, + cancelRequest, + refresh, + ] ); - const pagination = useMemo(() => { - return { - activePage, - pageCount: Math.round((results.length ?? 0) / CHARTS_PER_PAGE), - updatePagination: setActivePage, - }; - }, [activePage, results.length]); - - const resultPerPage = useMemo(() => { - const start = activePage * CHARTS_PER_PAGE; - return results.slice(start, start + CHARTS_PER_PAGE); - }, [results, activePage]); - - return { results: resultPerPage, isLoading, reset, progress, pagination }; + return { results, isLoading: progress !== null, reset, progress }; } /** diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts new file mode 100644 index 0000000000000..94a7d505b4556 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_common_chart_props.ts @@ -0,0 +1,222 @@ +/* + * 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 { FilterStateStore } from '@kbn/es-query'; +import { type TypedLensByValueInput } from '@kbn/lens-plugin/public'; +import { useTimeRangeUpdates } from '@kbn/ml-date-picker'; +import { useMemo } from 'react'; +import { fnOperationTypeMapping } from './constants'; +import { useDataSource } from '../../hooks/use_data_source'; +import { + ChangePointAnnotation, + FieldConfig, + useChangePointDetectionContext, +} from './change_point_detection_context'; + +/** + * Provides common props for the Lens Embeddable component + */ +export const useCommonChartProps = ({ + annotation, + fieldConfig, + previewMode = false, +}: { + fieldConfig: FieldConfig; + annotation: ChangePointAnnotation; + previewMode?: boolean; +}): Partial => { + const timeRange = useTimeRangeUpdates(); + const { dataView } = useDataSource(); + const { bucketInterval, resultQuery, resultFilters } = useChangePointDetectionContext(); + + const filters = useMemo(() => { + return [ + ...resultFilters, + ...(annotation.group + ? [ + { + meta: { + index: dataView.id!, + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: annotation.group.name, + params: { + query: annotation.group.value, + }, + }, + query: { + match_phrase: { + [annotation.group.name]: annotation.group.value, + }, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + ] + : []), + ]; + }, [dataView.id, annotation.group, resultFilters]); + + const gridAndLabelsVisibility = !previewMode; + + const attributes = useMemo(() => { + return { + title: annotation.group?.value ?? '', + description: '', + visualizationType: 'lnsXY', + type: 'lens', + references: [ + { + type: 'index-pattern', + id: dataView.id!, + name: 'indexpattern-datasource-layer-2d61a885-abb0-4d4e-a5f9-c488caec3c22', + }, + { + type: 'index-pattern', + id: dataView.id!, + name: 'xy-visualization-layer-8d26ab67-b841-4877-9d02-55bf270f9caf', + }, + ], + state: { + visualization: { + hideEndzones: true, + yLeftExtent: { + mode: 'dataBounds', + }, + legend: { + isVisible: false, + }, + valueLabels: 'hide', + fittingFunction: 'None', + // Updates per chart type + axisTitlesVisibilitySettings: { + x: gridAndLabelsVisibility, + yLeft: gridAndLabelsVisibility, + yRight: gridAndLabelsVisibility, + }, + tickLabelsVisibilitySettings: { + x: gridAndLabelsVisibility, + yLeft: gridAndLabelsVisibility, + yRight: gridAndLabelsVisibility, + }, + labelsOrientation: { + x: 0, + yLeft: 0, + yRight: 0, + }, + gridlinesVisibilitySettings: { + x: gridAndLabelsVisibility, + yLeft: gridAndLabelsVisibility, + yRight: gridAndLabelsVisibility, + }, + preferredSeriesType: 'line', + layers: [ + { + layerId: '2d61a885-abb0-4d4e-a5f9-c488caec3c22', + accessors: ['e9f26d17-fb36-4982-8539-03f1849cbed0'], + position: 'top', + seriesType: 'line', + showGridlines: false, + layerType: 'data', + xAccessor: '877e6638-bfaa-43ec-afb9-2241dc8e1c86', + }, + // Annotation layer + { + layerId: '8d26ab67-b841-4877-9d02-55bf270f9caf', + layerType: 'annotations', + annotations: [ + { + type: 'manual', + icon: 'triangle', + textVisibility: gridAndLabelsVisibility, + label: annotation.label, + key: { + type: 'point_in_time', + timestamp: annotation.timestamp, + }, + id: 'a8fb297c-8d96-4011-93c0-45af110d5302', + isHidden: false, + color: '#F04E98', + lineStyle: 'solid', + lineWidth: 1, + outside: false, + }, + ], + ignoreGlobalFilters: true, + }, + ], + }, + query: resultQuery, + filters, + datasourceStates: { + formBased: { + layers: { + '2d61a885-abb0-4d4e-a5f9-c488caec3c22': { + columns: { + '877e6638-bfaa-43ec-afb9-2241dc8e1c86': { + label: dataView.timeFieldName, + dataType: 'date', + operationType: 'date_histogram', + sourceField: dataView.timeFieldName, + isBucketed: true, + scale: 'interval', + params: { + interval: bucketInterval.expression, + includeEmptyRows: true, + dropPartials: false, + }, + }, + 'e9f26d17-fb36-4982-8539-03f1849cbed0': { + label: `${fieldConfig.fn}(${fieldConfig.metricField})`, + dataType: 'number', + operationType: fnOperationTypeMapping[fieldConfig.fn], + sourceField: fieldConfig.metricField, + isBucketed: false, + scale: 'ratio', + params: { + emptyAsNull: true, + }, + }, + }, + columnOrder: [ + '877e6638-bfaa-43ec-afb9-2241dc8e1c86', + 'e9f26d17-fb36-4982-8539-03f1849cbed0', + ], + incompleteColumns: {}, + }, + }, + }, + textBased: { + layers: {}, + }, + }, + }, + } as TypedLensByValueInput['attributes']; + }, [ + annotation.group?.value, + annotation.timestamp, + annotation.label, + dataView.id, + dataView.timeFieldName, + resultQuery, + filters, + bucketInterval.expression, + fieldConfig.fn, + fieldConfig.metricField, + gridAndLabelsVisibility, + ]); + + return { + timeRange, + filters, + query: resultQuery, + attributes, + }; +}; diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts index 5bfaf09693184..26e23622944d6 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts +++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_split_field_cardinality.ts @@ -11,6 +11,7 @@ import type { AggregationsCardinalityAggregate, SearchResponseBody, } from '@elastic/elasticsearch/lib/api/types'; +import usePrevious from 'react-use/lib/usePrevious'; import { useCancellableSearch } from '../../hooks/use_cancellable_search'; import { useDataSource } from '../../hooks/use_data_source'; @@ -23,6 +24,8 @@ export function useSplitFieldCardinality( splitField: string | undefined, query: QueryDslQueryContainer ) { + const prevSplitField = usePrevious(splitField); + const [cardinality, setCardinality] = useState(null); const { dataView } = useDataSource(); @@ -49,6 +52,7 @@ export function useSplitFieldCardinality( useEffect( function performCardinalityCheck() { + setCardinality(null); if (splitField === undefined) { return; } @@ -72,5 +76,5 @@ export function useSplitFieldCardinality( [getSplitFieldCardinality, requestPayload, cancelRequest, splitField] ); - return cardinality; + return prevSplitField !== splitField ? null : cardinality; } diff --git a/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts b/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts index 926189a84146b..0450905bde912 100644 --- a/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts +++ b/x-pack/plugins/aiops/public/hooks/use_cancellable_search.ts @@ -50,6 +50,7 @@ export function useCancellableSearch() { if (error.name === 'AbortError') { return resolve(null); } + setIsFetching(false); reject(error); }, }); From bb73249d81a1f212f37cd1475c8982086f6f770c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 12 Apr 2023 18:59:48 +0300 Subject: [PATCH 03/12] [Cases] Filter out alerts already attached to the case on the backend (#154629) ## Summary This PR filters out alerts already attached to a case on the backend. The schema of the alerts in a case is: ``` { alertId: string | string[] index: string | string[] } ``` So the `alertId` can contain multiple alert ids. In this case, if an ID is attached to the case it will be removed from the `alertId` array. If all alerts are attach to the case no attachment will be created. ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../common/models/case_with_comments.test.ts | 551 ++++++++++++++++++ .../common/models/case_with_comments.ts | 100 +++- .../attachments/operations/get.test.ts | 108 ++++ .../services/attachments/operations/get.ts | 47 ++ x-pack/plugins/cases/server/services/mocks.ts | 1 + .../common/lib/api/attachments.ts | 6 +- .../tests/common/comments/post_comment.ts | 75 ++- .../internal/bulk_create_attachments.ts | 177 ++++++ 8 files changed, 1050 insertions(+), 15 deletions(-) create mode 100644 x-pack/plugins/cases/server/common/models/case_with_comments.test.ts create mode 100644 x-pack/plugins/cases/server/services/attachments/operations/get.test.ts diff --git a/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts b/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts new file mode 100644 index 0000000000000..801f7bb9494e4 --- /dev/null +++ b/x-pack/plugins/cases/server/common/models/case_with_comments.test.ts @@ -0,0 +1,551 @@ +/* + * 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 { AttributesTypeAlerts } from '../../../common/api'; +import type { SavedObject } from '@kbn/core-saved-objects-api-server'; +import { CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; +import { createCasesClientMockArgs } from '../../client/mocks'; +import { mockCaseComments, mockCases } from '../../mocks'; +import { CaseCommentModel } from './case_with_comments'; + +describe('CaseCommentModel', () => { + const theCase = mockCases[0]; + const clientArgs = createCasesClientMockArgs(); + const createdDate = '2023-04-07T12:18:36.941Z'; + const userComment = { + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user as const, + owner: SECURITY_SOLUTION_OWNER, + }; + + const singleAlert = { + type: CommentType.alert as const, + owner: SECURITY_SOLUTION_OWNER, + alertId: 'test-id-1', + index: 'test-index-1', + rule: { + id: 'rule-id-1', + name: 'rule-name-1', + }, + }; + + const multipleAlert = { + ...singleAlert, + alertId: ['test-id-3', 'test-id-4', 'test-id-5'], + index: ['test-index-3', 'test-index-4', 'test-index-5'], + }; + + clientArgs.services.caseService.getCase.mockResolvedValue(theCase); + clientArgs.services.caseService.patchCase.mockResolvedValue(theCase); + clientArgs.services.attachmentService.create.mockResolvedValue(mockCaseComments[0]); + clientArgs.services.attachmentService.bulkCreate.mockResolvedValue({ + saved_objects: mockCaseComments, + }); + + const alertIdsAttachedToCase = new Set(['test-id-4']); + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValue( + alertIdsAttachedToCase + ); + + let model: CaseCommentModel; + + beforeAll(async () => { + model = await CaseCommentModel.create(theCase.id, clientArgs); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('does not remove comments when filtering out duplicate alerts', async () => { + await model.createComment({ + id: 'comment-1', + commentReq: userComment, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "attributes": Object { + "comment": "Wow, good luck catching that bad meanie!", + "created_at": "2023-04-07T12:18:36.941Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "type": "user", + "updated_at": null, + "updated_by": null, + }, + "id": "comment-1", + "references": Array [ + Object { + "id": "mock-id-1", + "name": "associated-cases", + "type": "cases", + }, + ], + "refresh": false, + }, + ], + ] + `); + }); + + it('does not remove alerts not attached to the case', async () => { + await model.createComment({ + id: 'comment-1', + commentReq: singleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "attributes": Object { + "alertId": Array [ + "test-id-1", + ], + "created_at": "2023-04-07T12:18:36.941Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "index": Array [ + "test-index-1", + ], + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "rule": Object { + "id": "rule-id-1", + "name": "rule-name-1", + }, + "type": "alert", + "updated_at": null, + "updated_by": null, + }, + "id": "comment-1", + "references": Array [ + Object { + "id": "mock-id-1", + "name": "associated-cases", + "type": "cases", + }, + ], + "refresh": false, + }, + ], + ] + `); + }); + + it('remove alerts attached to the case', async () => { + await model.createComment({ + id: 'comment-1', + commentReq: multipleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "attributes": Object { + "alertId": Array [ + "test-id-3", + "test-id-5", + ], + "created_at": "2023-04-07T12:18:36.941Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "index": Array [ + "test-index-3", + "test-index-5", + ], + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "rule": Object { + "id": "rule-id-1", + "name": "rule-name-1", + }, + "type": "alert", + "updated_at": null, + "updated_by": null, + }, + "id": "comment-1", + "references": Array [ + Object { + "id": "mock-id-1", + "name": "associated-cases", + "type": "cases", + }, + ], + "refresh": false, + }, + ], + ] + `); + }); + + it('remove multiple alerts', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-3', 'test-id-5']) + ); + + await model.createComment({ + id: 'comment-1', + commentReq: multipleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "attributes": Object { + "alertId": Array [ + "test-id-4", + ], + "created_at": "2023-04-07T12:18:36.941Z", + "created_by": Object { + "email": "damaged_raccoon@elastic.co", + "full_name": "Damaged Raccoon", + "profile_uid": "u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0", + "username": "damaged_raccoon", + }, + "index": Array [ + "test-index-4", + ], + "owner": "securitySolution", + "pushed_at": null, + "pushed_by": null, + "rule": Object { + "id": "rule-id-1", + "name": "rule-name-1", + }, + "type": "alert", + "updated_at": null, + "updated_by": null, + }, + "id": "comment-1", + "references": Array [ + Object { + "id": "mock-id-1", + "name": "associated-cases", + "type": "cases", + }, + ], + "refresh": false, + }, + ], + ] + `); + }); + + it('does not create attachments if all alerts are attached to the case', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-3', 'test-id-4', 'test-id-5']) + ); + + await model.createComment({ + id: 'comment-1', + commentReq: multipleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create).not.toHaveBeenCalled(); + }); + + it('does not create attachments if the alert is attached to the case', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-1']) + ); + + await model.createComment({ + id: 'comment-1', + commentReq: singleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.create).not.toHaveBeenCalled(); + }); + }); + + describe('bulkCreate', () => { + it('does not remove user comments when filtering out duplicate alerts', async () => { + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...userComment, + }, + { + id: 'comment-2', + ...singleAlert, + }, + { + id: 'comment-3', + ...multipleAlert, + }, + ], + }); + + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; + + const singleAlertCall = attachments[1] as SavedObject; + const multipleAlertsCall = attachments[2] as SavedObject; + + expect(attachments.length).toBe(3); + expect(attachments[0].attributes.type).toBe('user'); + expect(attachments[1].attributes.type).toBe('alert'); + expect(attachments[2].attributes.type).toBe('alert'); + + expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']); + expect(singleAlertCall.attributes.index).toEqual(['test-index-1']); + + expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + }); + + it('does not remove alerts not attached to the case', async () => { + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...singleAlert, + }, + ], + }); + + const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] + .attachments as Array>; + + expect(attachments.length).toBe(1); + expect(attachments[0].attributes.type).toBe('alert'); + expect(attachments[0].attributes.alertId).toEqual(['test-id-1']); + expect(attachments[0].attributes.index).toEqual(['test-index-1']); + }); + + it('remove alerts attached to the case', async () => { + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...multipleAlert, + }, + ], + }); + + const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] + .attachments as Array>; + + expect(attachments.length).toBe(1); + expect(attachments[0].attributes.type).toBe('alert'); + expect(attachments[0].attributes.alertId).toEqual(['test-id-3', 'test-id-5']); + expect(attachments[0].attributes.index).toEqual(['test-index-3', 'test-index-5']); + }); + + it('remove multiple alerts', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-3', 'test-id-5']) + ); + + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...multipleAlert, + }, + ], + }); + + const attachments = clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0] + .attachments as Array>; + + expect(attachments.length).toBe(1); + expect(attachments[0].attributes.type).toBe('alert'); + expect(attachments[0].attributes.alertId).toEqual(['test-id-4']); + expect(attachments[0].attributes.index).toEqual(['test-index-4']); + }); + + it('does not create attachments if all alerts are attached to the case', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-3', 'test-id-4', 'test-id-5']) + ); + + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...multipleAlert, + }, + ], + }); + + expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled(); + }); + + it('does not create attachments if the alert is attached to the case', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-1']) + ); + + await model.createComment({ + id: 'comment-1', + commentReq: singleAlert, + createdDate, + }); + + expect(clientArgs.services.attachmentService.bulkCreate).not.toHaveBeenCalled(); + }); + + it('remove alerts from multiple attachments', async () => { + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...userComment, + }, + { + id: 'comment-2', + ...singleAlert, + }, + { + id: 'comment-3', + ...singleAlert, + }, + { + id: 'comment-4', + ...multipleAlert, + }, + { + id: 'comment-5', + ...multipleAlert, + }, + ], + }); + + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; + + const singleAlertCall = attachments[1] as SavedObject; + const multipleAlertsCall = attachments[2] as SavedObject; + + expect(attachments.length).toBe(3); + expect(attachments[0].attributes.type).toBe('user'); + expect(attachments[1].attributes.type).toBe('alert'); + expect(attachments[2].attributes.type).toBe('alert'); + + expect(singleAlertCall.attributes.alertId).toEqual(['test-id-1']); + expect(singleAlertCall.attributes.index).toEqual(['test-index-1']); + + expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + }); + + it('remove alerts from multiple attachments on the same request', async () => { + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...userComment, + }, + { + id: 'comment-2', + ...singleAlert, + }, + { + id: 'comment-3', + ...multipleAlert, + alertId: ['test-id-1', 'test-id-2'], + index: ['test-index-1', 'test-index-2'], + }, + { + id: 'comment-4', + ...multipleAlert, + alertId: ['test-id-2', 'test-id-4', 'test-id-5'], + index: ['test-index-1', 'test-index-4', 'test-index-5'], + }, + ], + }); + + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; + + const alertOne = attachments[1] as SavedObject; + const alertTwo = attachments[2] as SavedObject; + const alertThree = attachments[3] as SavedObject; + + expect(attachments.length).toBe(4); + expect(attachments[0].attributes.type).toBe('user'); + expect(attachments[1].attributes.type).toBe('alert'); + expect(attachments[2].attributes.type).toBe('alert'); + expect(attachments[3].attributes.type).toBe('alert'); + + expect(alertOne.attributes.alertId).toEqual(['test-id-1']); + expect(alertOne.attributes.index).toEqual(['test-index-1']); + + expect(alertTwo.attributes.alertId).toEqual(['test-id-2']); + expect(alertTwo.attributes.index).toEqual(['test-index-2']); + + expect(alertThree.attributes.alertId).toEqual(['test-id-5']); + expect(alertThree.attributes.index).toEqual(['test-index-5']); + }); + + it('remove alerts from multiple attachments with multiple alerts attached to the case', async () => { + clientArgs.services.attachmentService.getter.getAllAlertIds.mockResolvedValueOnce( + new Set(['test-id-1', 'test-id-4']) + ); + await model.bulkCreate({ + attachments: [ + { + id: 'comment-1', + ...userComment, + }, + { + id: 'comment-2', + ...singleAlert, + }, + { + id: 'comment-3', + ...multipleAlert, + }, + ], + }); + + const attachments = + clientArgs.services.attachmentService.bulkCreate.mock.calls[0][0].attachments; + + const multipleAlertsCall = attachments[1] as SavedObject; + + expect(attachments.length).toBe(2); + expect(attachments[0].attributes.type).toBe('user'); + expect(attachments[1].attributes.type).toBe('alert'); + + expect(multipleAlertsCall.attributes.alertId).toEqual(['test-id-3', 'test-id-5']); + expect(multipleAlertsCall.attributes.index).toEqual(['test-index-3', 'test-index-5']); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/common/models/case_with_comments.ts b/x-pack/plugins/cases/server/common/models/case_with_comments.ts index 04b3d31486546..2febbd22da77f 100644 --- a/x-pack/plugins/cases/server/common/models/case_with_comments.ts +++ b/x-pack/plugins/cases/server/common/models/case_with_comments.ts @@ -40,9 +40,11 @@ import { getOrUpdateLensReferences, isCommentRequestTypeAlert, getAlertInfoFromComments, + getIDsAndIndicesAsArrays, } from '../utils'; type CaseCommentModelParams = Omit; +type CommentRequestWithId = Array<{ id: string } & CommentRequest>; /** * This class represents a case that can have a comment attached to it. @@ -213,14 +215,23 @@ export class CaseCommentModel { }): Promise { try { await this.validateCreateCommentRequest([commentReq]); + const attachmentsWithoutDuplicateAlerts = await this.filterDuplicatedAlerts([ + { ...commentReq, id }, + ]); + + if (attachmentsWithoutDuplicateAlerts.length === 0) { + return this; + } + + const { id: commentId, ...attachment } = attachmentsWithoutDuplicateAlerts[0]; - const references = [...this.buildRefsToCase(), ...this.getCommentReferences(commentReq)]; + const references = [...this.buildRefsToCase(), ...this.getCommentReferences(attachment)]; const [comment, commentableCase] = await Promise.all([ this.params.services.attachmentService.create({ attributes: transformNewComment({ createdDate, - ...commentReq, + ...attachment, ...this.params.user, }), references, @@ -231,8 +242,8 @@ export class CaseCommentModel { ]); await Promise.all([ - commentableCase.handleAlertComments([commentReq]), - this.createCommentUserAction(comment, commentReq), + commentableCase.handleAlertComments([attachment]), + this.createCommentUserAction(comment, attachment), ]); return commentableCase; @@ -245,8 +256,73 @@ export class CaseCommentModel { } } + private async filterDuplicatedAlerts( + attachments: CommentRequestWithId + ): Promise { + /** + * This function removes the elements in items that exist at the passed in positions. + */ + const removeItemsByPosition = (items: string[], positionsToRemove: number[]): string[] => + items.filter((_, itemIndex) => !positionsToRemove.some((position) => position === itemIndex)); + + const dedupedAlertAttachments: CommentRequestWithId = []; + const idsAlreadySeen = new Set(); + const alertsAttachedToCase = await this.params.services.attachmentService.getter.getAllAlertIds( + { + caseId: this.caseInfo.id, + } + ); + + attachments.forEach((attachment) => { + if (!isCommentRequestTypeAlert(attachment)) { + dedupedAlertAttachments.push(attachment); + return; + } + + const { ids, indices } = getIDsAndIndicesAsArrays(attachment); + const idPositionsThatAlreadyExistInCase: number[] = []; + + ids.forEach((id, index) => { + if (alertsAttachedToCase.has(id) || idsAlreadySeen.has(id)) { + idPositionsThatAlreadyExistInCase.push(index); + } + + idsAlreadySeen.add(id); + }); + + const alertIdsNotAlreadyAttachedToCase = removeItemsByPosition( + ids, + idPositionsThatAlreadyExistInCase + ); + const alertIndicesNotAlreadyAttachedToCase = removeItemsByPosition( + indices, + idPositionsThatAlreadyExistInCase + ); + + if ( + alertIdsNotAlreadyAttachedToCase.length > 0 && + alertIdsNotAlreadyAttachedToCase.length === alertIndicesNotAlreadyAttachedToCase.length + ) { + dedupedAlertAttachments.push({ + ...attachment, + alertId: alertIdsNotAlreadyAttachedToCase, + index: alertIndicesNotAlreadyAttachedToCase, + }); + } + }); + + return dedupedAlertAttachments; + } + + private getAlertAttachments(attachments: CommentRequest[]): CommentRequestAlertType[] { + return attachments.filter( + (attachment): attachment is CommentRequestAlertType => attachment.type === CommentType.alert + ); + } + private async validateCreateCommentRequest(req: CommentRequest[]) { - const hasAlertsInRequest = req.some((request) => isCommentRequestTypeAlert(request)); + const alertAttachments = this.getAlertAttachments(req); + const hasAlertsInRequest = alertAttachments.length > 0; if (hasAlertsInRequest && this.caseInfo.attributes.status === CaseStatuses.closed) { throw Boom.badRequest('Alert cannot be attached to a closed case'); @@ -290,9 +366,7 @@ export class CaseCommentModel { } private async handleAlertComments(attachments: CommentRequest[]) { - const alertAttachments = attachments.filter( - (attachment): attachment is CommentRequestAlertType => attachment.type === CommentType.alert - ); + const alertAttachments = this.getAlertAttachments(attachments); const alerts = getAlertInfoFromComments(alertAttachments); @@ -392,16 +466,22 @@ export class CaseCommentModel { public async bulkCreate({ attachments, }: { - attachments: Array<{ id: string } & CommentRequest>; + attachments: CommentRequestWithId; }): Promise { try { await this.validateCreateCommentRequest(attachments); + const attachmentWithoutDuplicateAlerts = await this.filterDuplicatedAlerts(attachments); + + if (attachmentWithoutDuplicateAlerts.length === 0) { + return this; + } + const caseReference = this.buildRefsToCase(); const [newlyCreatedAttachments, commentableCase] = await Promise.all([ this.params.services.attachmentService.bulkCreate({ - attachments: attachments.map(({ id, ...attachment }) => { + attachments: attachmentWithoutDuplicateAlerts.map(({ id, ...attachment }) => { return { attributes: transformNewComment({ createdDate: new Date().toISOString(), diff --git a/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts b/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts new file mode 100644 index 0000000000000..0f8de698057d1 --- /dev/null +++ b/x-pack/plugins/cases/server/services/attachments/operations/get.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { createPersistableStateAttachmentTypeRegistryMock } from '../../../attachment_framework/mocks'; +import { AttachmentGetter } from './get'; + +describe('AttachmentService getter', () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + const persistableStateAttachmentTypeRegistry = createPersistableStateAttachmentTypeRegistryMock(); + let attachmentGetter: AttachmentGetter; + + beforeEach(async () => { + jest.clearAllMocks(); + attachmentGetter = new AttachmentGetter({ + log: mockLogger, + persistableStateAttachmentTypeRegistry, + unsecuredSavedObjectsClient, + }); + }); + + describe('getAllAlertIds', () => { + const aggsRes = { + aggregations: { alertIds: { buckets: [{ key: 'alert-id-1' }, { key: 'alert-id-2' }] } }, + saved_objects: [], + page: 1, + per_page: 0, + total: 0, + }; + + unsecuredSavedObjectsClient.find.mockResolvedValue(aggsRes); + + const caseId = 'test-case'; + + it('returns the alert ids correctly', async () => { + const res = await attachmentGetter.getAllAlertIds({ caseId }); + expect(Array.from(res.values())).toEqual(['alert-id-1', 'alert-id-2']); + }); + + it('calls find with correct arguments', async () => { + await attachmentGetter.getAllAlertIds({ caseId }); + expect(unsecuredSavedObjectsClient.find.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "aggs": Object { + "alertIds": Object { + "terms": Object { + "field": "cases-comments.attributes.alertId", + "size": 1000, + }, + }, + }, + "filter": Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-comments.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "alert", + }, + ], + "function": "is", + "type": "function", + }, + "hasReference": Object { + "id": "test-case", + "type": "cases", + }, + "perPage": 0, + "sortField": "created_at", + "sortOrder": "asc", + "type": "cases-comments", + }, + ], + ] + `); + }); + + it('returns an empty set when there is no response', async () => { + // @ts-expect-error + unsecuredSavedObjectsClient.find.mockResolvedValue({}); + + const res = await attachmentGetter.getAllAlertIds({ caseId }); + expect(Array.from(res.values())).toEqual([]); + }); + + it('remove duplicate keys', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValue({ + ...aggsRes, + aggregations: { alertIds: { buckets: [{ key: 'alert-id-1' }, { key: 'alert-id-1' }] } }, + }); + + const res = await attachmentGetter.getAllAlertIds({ caseId }); + expect(Array.from(res.values())).toEqual(['alert-id-1']); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/services/attachments/operations/get.ts b/x-pack/plugins/cases/server/services/attachments/operations/get.ts index b673039d33af1..3db7059b3977b 100644 --- a/x-pack/plugins/cases/server/services/attachments/operations/get.ts +++ b/x-pack/plugins/cases/server/services/attachments/operations/get.ts @@ -11,6 +11,7 @@ import { FILE_SO_TYPE } from '@kbn/files-plugin/common'; import { CASE_COMMENT_SAVED_OBJECT, CASE_SAVED_OBJECT, + MAX_ALERTS_PER_CASE, MAX_DOCS_PER_PAGE, } from '../../../../common/constants'; import { buildFilter, combineFilters } from '../../../client/utils'; @@ -38,6 +39,14 @@ import { getCaseReferenceId } from '../../../common/references'; type GetAllAlertsAttachToCaseArgs = AttachedToCaseArgs; +interface AlertIdsAggsResult { + alertIds: { + buckets: Array<{ + key: string; + }>; + }; +} + export class AttachmentGetter { constructor(private readonly context: ServiceContext) {} @@ -145,6 +154,44 @@ export class AttachmentGetter { } } + /** + * Retrieves all the alerts attached to a case. + */ + public async getAllAlertIds({ caseId }: { caseId: string }): Promise> { + try { + this.context.log.debug(`Attempting to GET all alerts ids for case id ${caseId}`); + const alertsFilter = buildFilter({ + filters: [CommentType.alert], + field: 'type', + operator: 'or', + type: CASE_COMMENT_SAVED_OBJECT, + }); + + const res = await this.context.unsecuredSavedObjectsClient.find({ + type: CASE_COMMENT_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + sortField: 'created_at', + sortOrder: 'asc', + filter: alertsFilter, + perPage: 0, + aggs: { + alertIds: { + terms: { + field: `${CASE_COMMENT_SAVED_OBJECT}.attributes.alertId`, + size: MAX_ALERTS_PER_CASE, + }, + }, + }, + }); + + const alertIds = res.aggregations?.alertIds.buckets.map((bucket) => bucket.key) ?? []; + return new Set(alertIds); + } catch (error) { + this.context.log.error(`Error on GET all alerts ids for case id ${caseId}: ${error}`); + throw error; + } + } + public async get({ attachmentId, }: GetAttachmentArgs): Promise> { diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index 5ebec19f034a0..57a81e9afc94e 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -154,6 +154,7 @@ const createAttachmentGetterServiceMock = (): AttachmentGetterServiceMock => { getCaseCommentStats: jest.fn(), getAttachmentIdsForCases: jest.fn(), getFileAttachments: jest.fn(), + getAllAlertIds: jest.fn(), }; return service as unknown as AttachmentGetterServiceMock; diff --git a/x-pack/test/cases_api_integration/common/lib/api/attachments.ts b/x-pack/test/cases_api_integration/common/lib/api/attachments.ts index c49a375ed0b94..0a85ed21569ca 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/attachments.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/attachments.ts @@ -127,7 +127,7 @@ export const createCaseAndBulkCreateAttachments = async ({ }; export const getAttachments = (numberOfAttachments: number): BulkCreateCommentRequest => { - return [...Array(numberOfAttachments)].map((index) => { + return [...Array(numberOfAttachments)].map((_, index) => { if (index % 0) { return { type: CommentType.user, @@ -138,8 +138,8 @@ export const getAttachments = (numberOfAttachments: number): BulkCreateCommentRe return { type: CommentType.alert, - alertId: `test-id-${index + 1}`, - index: `test-index-${index + 1}`, + alertId: [`test-id-${index + 1}`], + index: [`test-index-${index + 1}`], rule: { id: `rule-test-id-${index + 1}`, name: `Test ${index + 1}`, diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts index af3bf769a7489..0ad24de82b586 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/post_comment.ts @@ -15,6 +15,7 @@ import { AttributesTypeAlerts, CaseStatuses, CommentRequestExternalReferenceSOType, + CommentRequestAlertType, } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -26,6 +27,7 @@ import { getFilesAttachmentReq, fileAttachmentMetadata, fileMetadata, + postCommentAlertMultipleIdsReq, } from '../../../../common/lib/mock'; import { deleteAllCaseItems, @@ -39,6 +41,7 @@ import { updateCase, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + getAllComments, } from '../../../../common/lib/api'; import { createSignalsIndex, @@ -121,8 +124,8 @@ export default ({ getService }: FtrProviderContext): void => { expect(comment).to.eql({ type: postCommentAlertReq.type, - alertId: postCommentAlertReq.alertId, - index: postCommentAlertReq.index, + alertId: [postCommentAlertReq.alertId], + index: [postCommentAlertReq.index], rule: postCommentAlertReq.rule, created_by: defaultUser, pushed_at: null, @@ -937,6 +940,74 @@ export default ({ getService }: FtrProviderContext): void => { } }); + describe('alert filtering', () => { + it('not create a new attachment if the alert is already attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertReq, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('should not create a new attachment if the alerts are already attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + }); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('should create a new attachment without alerts attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await createComment({ + supertest, + caseId: postedCase.id, + params: postCommentAlertMultipleIdsReq, + }); + + await createComment({ + supertest, + caseId: postedCase.id, + params: { + ...postCommentAlertMultipleIdsReq, + alertId: ['test-id-1', 'test-id-2', 'test-id-3'], + index: ['test-index-1', 'test-index-2', 'test-index-3'], + }, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(2); + + const secondAttachment = attachments[1] as CommentRequestAlertType; + + expect(secondAttachment.alertId).to.eql(['test-id-3']); + expect(secondAttachment.index).to.eql(['test-index-3']); + }); + }); + describe('rbac', () => { afterEach(async () => { await deleteAllCaseItems(es); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts index d1c56417acd50..36240502909d3 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/internal/bulk_create_attachments.ts @@ -13,6 +13,7 @@ import { BulkCreateCommentRequest, CaseResponse, CaseStatuses, + CommentRequestAlertType, CommentRequestExternalReferenceSOType, CommentType, } from '@kbn/cases-plugin/common/api'; @@ -27,6 +28,7 @@ import { fileAttachmentMetadata, postExternalReferenceSOReq, fileMetadata, + postCommentAlertMultipleIdsReq, } from '../../../../common/lib/mock'; import { deleteAllCaseItems, @@ -40,6 +42,7 @@ import { removeServerGeneratedPropertiesFromUserAction, createAndUploadFile, deleteAllFiles, + getAllComments, } from '../../../../common/lib/api'; import { createSignalsIndex, @@ -1216,6 +1219,180 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + describe('alert filtering', () => { + it('does not create a new attachment if the alert is already attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertReq], + expectedHttpCode: 200, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertReq], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('does not create a new attachment if the alert is already attached to the case on the same request', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertReq, postCommentAlertReq], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('should not create a new attachment if the alerts are already attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertMultipleIdsReq], + expectedHttpCode: 200, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertMultipleIdsReq], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('should not create a new attachment if the alerts are already attached to the case on the same request', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertMultipleIdsReq, postCommentAlertMultipleIdsReq], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(1); + }); + + it('should create a new attachment without alerts attached to the case', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertMultipleIdsReq], + expectedHttpCode: 200, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + { + ...postCommentAlertMultipleIdsReq, + alertId: ['test-id-1', 'test-id-2', 'test-id-3'], + index: ['test-index-1', 'test-index-2', 'test-index-3'], + }, + ], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(2); + + const secondAttachment = attachments[1] as CommentRequestAlertType; + + expect(secondAttachment.alertId).to.eql(['test-id-3']); + expect(secondAttachment.index).to.eql(['test-index-3']); + }); + + it('should create a new attachment without alerts attached to the case on the same request', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + postCommentAlertMultipleIdsReq, + { + ...postCommentAlertMultipleIdsReq, + alertId: ['test-id-1', 'test-id-2', 'test-id-3'], + index: ['test-index-1', 'test-index-2', 'test-index-3'], + }, + ], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(2); + + const secondAttachment = attachments[1] as CommentRequestAlertType; + + expect(secondAttachment.alertId).to.eql(['test-id-3']); + expect(secondAttachment.index).to.eql(['test-index-3']); + }); + + it('does not remove user comments when filtering out duplicate alerts', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [postCommentAlertMultipleIdsReq], + expectedHttpCode: 200, + }); + + await bulkCreateAttachments({ + supertest, + caseId: postedCase.id, + params: [ + postCommentUserReq, + { + ...postCommentAlertMultipleIdsReq, + alertId: ['test-id-1', 'test-id-2', 'test-id-3'], + index: ['test-index-1', 'test-index-2', 'test-index-3'], + }, + postCommentUserReq, + ], + expectedHttpCode: 200, + }); + + const attachments = await getAllComments({ supertest, caseId: postedCase.id }); + expect(attachments.length).to.eql(4); + + const firstAlert = attachments[0] as CommentRequestAlertType; + const firstUserComment = attachments[1] as CommentRequestAlertType; + const secondAlert = attachments[2] as CommentRequestAlertType; + const secondUserComment = attachments[3] as CommentRequestAlertType; + + expect(firstUserComment.type).to.eql('user'); + expect(secondUserComment.type).to.eql('user'); + expect(firstAlert.type).to.eql('alert'); + expect(secondAlert.type).to.eql('alert'); + + expect(firstAlert.alertId).to.eql(['test-id-1', 'test-id-2']); + expect(firstAlert.index).to.eql(['test-index', 'test-index-2']); + expect(secondAlert.alertId).to.eql(['test-id-3']); + expect(secondAlert.index).to.eql(['test-index-3']); + }); + }); + describe('rbac', () => { afterEach(async () => { await deleteAllCaseItems(es); From e546d5297d6a6d0e8c5ebc00a0eeb1d901cf703b Mon Sep 17 00:00:00 2001 From: Paulo Henrique Date: Wed, 12 Apr 2023 13:01:43 -0300 Subject: [PATCH 04/12] [Cloud Security] [Findings] [Vulnerabilities] Vulnerabilities Table (#154388) ### Summary Ticket: #150510 This PR adds the Vulnerability Management table to the Findings page - Vulnerabilities tab. It also - Fetches results from the `logs-cloud_security_posture.vulnerabilities-latest`. - Uses EuiGrid component - Has sorting and pagination controls (it shares page size state with configurations findings) - All columns except the Actions column are sortable - Items per page to default to 10, with options of 10, 25, 100 - Add an Empty results message in case of no results This PR also added two new hooks: - *useCloudPostureTable*: Hook for managing common table state and methods for Cloud Posture - *useDataViewForIndexPattern*: Hook that extracts data view from the common `logs-*` and filters the fields to narrow to a specific index pattern. This allows us to have a search bar and filter working without the need to create a Data View. Todo: Add tests for the vulnerability table ### Screenshots Initial State ![image](https://user-images.githubusercontent.com/19270322/229942980-89c9666c-e950-4347-9a72-ed18c5d3504d.png) Filtering ![image](https://user-images.githubusercontent.com/19270322/229943055-259f0d99-d770-4407-98f0-bfabf69e4b76.png) ![image](https://user-images.githubusercontent.com/19270322/229943116-61c4342f-693d-48dd-95d4-11d3e091b2cd.png) ![image](https://user-images.githubusercontent.com/19270322/229943158-138ded65-59b9-4beb-b667-85a3fe45012c.png) Empty State (Sharing component with Configurations tab) ![image](https://user-images.githubusercontent.com/19270322/229943385-c020aee4-819e-49f5-8051-19e747911e98.png) Sorting ![image](https://user-images.githubusercontent.com/19270322/229943557-79246e26-7e62-4c68-8249-08b725f01f4f.png) --- .../common/constants.ts | 3 +- .../cloud_security_posture/common/types.ts | 4 - .../common/api/use_filtered_data_view.ts | 45 ++++ .../hooks/use_cloud_posture_table/index.ts | 8 + .../use_cloud_posture_table.ts | 100 ++++++++ .../hooks/use_cloud_posture_table/utils.ts | 113 +++++++++ .../{pages/configurations => common}/types.ts | 2 +- .../utils/get_limit_properties.test.ts | 0 .../utils/get_limit_properties.ts | 2 +- .../public/common/utils/show_error_toast.ts | 22 ++ .../components/vulnerability_badges.tsx | 80 +++--- .../latest_findings_container.test.tsx | 2 +- .../latest_findings_container.tsx | 80 +++--- .../latest_findings/use_latest_findings.ts | 20 +- .../findings_by_resource_container.tsx | 54 ++-- .../findings_by_resource_table.test.tsx | 1 + .../resource_findings_container.tsx | 58 ++--- .../use_resource_findings.ts | 4 +- .../use_findings_by_resource.ts | 4 +- .../layout/findings_group_by_selector.tsx | 2 +- .../layout/findings_search_bar.tsx | 2 +- .../pages/configurations/utils/get_filters.ts | 2 +- .../pages/configurations/utils/utils.ts | 99 +------- .../hooks/use_latest_vulnerabilities.tsx | 56 +++++ .../public/pages/vulnerabilities/types.ts | 118 +++++++++ .../public/pages/vulnerabilities/utils.ts | 87 +++++++ .../pages/vulnerabilities/vulnerabilities.tsx | 232 +++++++++++++++++- 27 files changed, 893 insertions(+), 307 deletions(-) create mode 100644 x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts rename x-pack/plugins/cloud_security_posture/public/{pages/configurations => common}/types.ts (92%) rename x-pack/plugins/cloud_security_posture/public/{pages/configurations => common}/utils/get_limit_properties.test.ts (100%) rename x-pack/plugins/cloud_security_posture/public/{pages/configurations => common}/utils/get_limit_properties.ts (94%) create mode 100644 x-pack/plugins/cloud_security_posture/public/common/utils/show_error_toast.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index fd99edd8705e7..c1adc5c806334 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -36,9 +36,10 @@ export const VULNERABILITIES_INDEX_DEFAULT_NS = export const LATEST_VULNERABILITIES_INDEX_TEMPLATE_NAME = 'logs-cloud_security_posture.vulnerabilities_latest'; export const LATEST_VULNERABILITIES_INDEX_PATTERN = - 'logs-cloud_security_posture.vulnerabilities_latest-*'; + 'logs-cloud_security_posture.vulnerabilities_latest*'; export const LATEST_VULNERABILITIES_INDEX_DEFAULT_NS = 'logs-cloud_security_posture.vulnerabilities_latest-default'; +export const DATA_VIEW_INDEX_PATTERN = 'logs-*'; export const CSP_INGEST_TIMESTAMP_PIPELINE = 'cloud_security_posture_add_ingest_timestamp_pipeline'; export const CSP_LATEST_FINDINGS_INGEST_TIMESTAMP_PIPELINE = diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index 7b90b38d7a76f..8bd1c86eac3f6 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -106,7 +106,3 @@ export type BenchmarkName = CspRuleTemplateMetadata['benchmark']['name']; export type PostureInput = typeof SUPPORTED_CLOUDBEAT_INPUTS[number]; export type CloudSecurityPolicyTemplate = typeof SUPPORTED_POLICY_TEMPLATES[number]; export type PosturePolicyTemplate = Extract; - -// Vulnerability Integration Types -export type CVSSVersion = '2.0' | '3.0'; -export type SeverityStatus = 'Low' | 'Medium' | 'High' | 'Critical'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts new file mode 100644 index 0000000000000..c2104478edb87 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_filtered_data_view.ts @@ -0,0 +1,45 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DataView } from '@kbn/data-plugin/common'; +import { DATA_VIEW_INDEX_PATTERN } from '../../../common/constants'; +import { CspClientPluginStartDeps } from '../../types'; + +/** + * Returns the common logs-* data view with fields filtered by + * fields present in the given index pattern + */ +export const useFilteredDataView = (indexPattern: string) => { + const { + data: { dataViews }, + } = useKibana().services; + + const findDataView = async (): Promise => { + const dataView = (await dataViews.find(DATA_VIEW_INDEX_PATTERN))?.[0]; + if (!dataView) { + throw new Error('Findings data view not found'); + } + + const indexPatternFields = await dataViews.getFieldsForWildcard({ + pattern: indexPattern, + }); + + if (!indexPatternFields) { + throw new Error('Error fetching fields for the index pattern'); + } + + dataView.fields = dataView.fields.filter((field) => + indexPatternFields.some((indexPatternField) => indexPatternField.name === field.name) + ) as DataView['fields']; + + return dataView; + }; + + return useQuery(['latest_findings_data_view', indexPattern], findDataView); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts new file mode 100644 index 0000000000000..06ad2776fb305 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/index.ts @@ -0,0 +1,8 @@ +/* + * 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 * from './use_cloud_posture_table'; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts new file mode 100644 index 0000000000000..2c2c392a91c37 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/use_cloud_posture_table.ts @@ -0,0 +1,100 @@ +/* + * 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 { useCallback } from 'react'; +import { type DataView } from '@kbn/data-views-plugin/common'; +import { useUrlQuery } from '../use_url_query'; +import { usePageSize } from '../use_page_size'; +import { getDefaultQuery, useBaseEsQuery, usePersistedQuery } from './utils'; + +/* + Hook for managing common table state and methods for Cloud Posture +*/ +export const useCloudPostureTable = ({ + defaultQuery = getDefaultQuery, + dataView, + paginationLocalStorageKey, +}: { + defaultQuery?: (params: any) => any; + dataView: DataView; + paginationLocalStorageKey: string; +}) => { + const getPersistedDefaultQuery = usePersistedQuery(defaultQuery); + const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); + const { pageSize, setPageSize } = usePageSize(paginationLocalStorageKey); + + const onChangeItemsPerPage = useCallback( + (newPageSize) => { + setPageSize(newPageSize); + setUrlQuery({ + pageIndex: 0, + }); + }, + [setUrlQuery, setPageSize] + ); + + const onChangePage = useCallback( + (newPageIndex) => { + setUrlQuery({ + pageIndex: newPageIndex, + }); + }, + [setUrlQuery] + ); + + const onSort = useCallback( + (sort) => { + setUrlQuery({ + sort, + }); + }, + [setUrlQuery] + ); + + const setTableOptions = useCallback( + ({ page, sort }) => { + setPageSize(page.size); + setUrlQuery({ + sort, + pageIndex: page.index, + }); + }, + [setUrlQuery, setPageSize] + ); + + /** + * Page URL query to ES query + */ + const baseEsQuery = useBaseEsQuery({ + dataView, + filters: urlQuery.filters, + query: urlQuery.query, + }); + + const handleUpdateQuery = useCallback( + (query) => { + setUrlQuery({ ...query, pageIndex: 0 }); + }, + [setUrlQuery] + ); + + return { + setUrlQuery, + sort: urlQuery.sort, + filters: urlQuery.filters, + query: baseEsQuery.query, + queryError: baseEsQuery.error, + pageIndex: urlQuery.pageIndex, + urlQuery, + setTableOptions, + handleUpdateQuery, + pageSize, + setPageSize, + onChangeItemsPerPage, + onChangePage, + onSort, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts new file mode 100644 index 0000000000000..a5c57c2929015 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_cloud_posture_table/utils.ts @@ -0,0 +1,113 @@ +/* + * 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 { useEffect, useCallback, useMemo } from 'react'; +import { buildEsQuery } from '@kbn/es-query'; +import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { type Query } from '@kbn/es-query'; +import { useKibana } from '../use_kibana'; +import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../types'; + +const getBaseQuery = ({ dataView, query, filters }: FindingsBaseURLQuery & FindingsBaseProps) => { + try { + return { + query: buildEsQuery(dataView, query, filters), // will throw for malformed query + }; + } catch (error) { + return { + query: undefined, + error: error instanceof Error ? error : new Error('Unknown Error'), + }; + } +}; + +type TablePagination = NonNullable['pagination']>; + +export const getPaginationTableParams = ( + params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, + pageSizeOptions = [10, 25, 100], + showPerPageOptions = true +): Required => ({ + ...params, + pageSizeOptions, + showPerPageOptions, +}); + +export const getPaginationQuery = ({ + pageIndex, + pageSize, +}: Pick) => ({ + from: pageIndex * pageSize, + size: pageSize, +}); + +export const useBaseEsQuery = ({ + dataView, + filters, + query, +}: FindingsBaseURLQuery & FindingsBaseProps) => { + const { + notifications: { toasts }, + data: { + query: { filterManager, queryString }, + }, + } = useKibana().services; + + const baseEsQuery = useMemo( + () => getBaseQuery({ dataView, filters, query }), + [dataView, filters, query] + ); + + /** + * Sync filters with the URL query + */ + useEffect(() => { + filterManager.setAppFilters(filters); + queryString.setQuery(query); + }, [filters, filterManager, queryString, query]); + + const handleMalformedQueryError = () => { + const error = baseEsQuery.error; + if (error) { + toasts.addError(error, { + title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { + defaultMessage: 'Query Error', + }), + toastLifeTimeMs: 1000 * 5, + }); + } + }; + + useEffect(handleMalformedQueryError, [baseEsQuery.error, toasts]); + + return baseEsQuery; +}; + +export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { + const { + data: { + query: { filterManager, queryString }, + }, + } = useKibana().services; + + return useCallback( + () => + getter({ + filters: filterManager.getAppFilters(), + query: queryString.getQuery() as Query, + }), + [getter, filterManager, queryString] + ); +}; + +export const getDefaultQuery = ({ query, filters }: any): any => ({ + query, + filters, + sort: { field: '@timestamp', direction: 'desc' }, + pageIndex: 0, +}); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/types.ts b/x-pack/plugins/cloud_security_posture/public/common/types.ts similarity index 92% rename from x-pack/plugins/cloud_security_posture/public/pages/configurations/types.ts rename to x-pack/plugins/cloud_security_posture/public/common/types.ts index 8e8a2b3ea0ad3..65dfaf0f63ae5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/types.ts @@ -7,7 +7,7 @@ import type { Criteria } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { BoolQuery, Filter, Query } from '@kbn/es-query'; -import { CspFinding } from '../../../common/schemas/csp_finding'; +import { CspFinding } from '../../common/schemas/csp_finding'; export type FindingsGroupByKind = 'default' | 'resource'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_limit_properties.test.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/get_limit_properties.test.ts similarity index 100% rename from x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_limit_properties.test.ts rename to x-pack/plugins/cloud_security_posture/public/common/utils/get_limit_properties.test.ts diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_limit_properties.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/get_limit_properties.ts similarity index 94% rename from x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_limit_properties.ts rename to x-pack/plugins/cloud_security_posture/public/common/utils/get_limit_properties.ts index 98affc0b40ee5..f09990bc3f854 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_limit_properties.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/utils/get_limit_properties.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; +import { MAX_FINDINGS_TO_LOAD } from '../constants'; export const getLimitProperties = ( totalItems: number, diff --git a/x-pack/plugins/cloud_security_posture/public/common/utils/show_error_toast.ts b/x-pack/plugins/cloud_security_posture/public/common/utils/show_error_toast.ts new file mode 100644 index 0000000000000..75316d9495b26 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/common/utils/show_error_toast.ts @@ -0,0 +1,22 @@ +/* + * 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 { CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { extractErrorMessage } from '../../../common/utils/helpers'; + +const SEARCH_FAILED_TEXT = i18n.translate( + 'xpack.csp.findings.findingsErrorToast.searchFailedTitle', + { defaultMessage: 'Search failed' } +); + +export const showErrorToast = ( + toasts: CoreStart['notifications']['toasts'], + error: unknown +): void => { + if (error instanceof Error) toasts.addError(error, { title: SEARCH_FAILED_TEXT }); + else toasts.addDanger(extractErrorMessage(error, SEARCH_FAILED_TEXT)); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx index d3fa81a6cc189..713b29d06b573 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/vulnerability_badges.tsx @@ -5,30 +5,29 @@ * 2.0. */ -import { EuiBadge, EuiText, EuiTextColor, useEuiFontSize, useEuiTheme } from '@elastic/eui'; +import { EuiBadge, EuiIcon, EuiTextColor, useEuiFontSize } from '@elastic/eui'; import React from 'react'; import { css } from '@emotion/react'; import { float } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { i18n } from '@kbn/i18n'; -import { CVSSVersion, SeverityStatus } from '../../common/types'; +import { getCvsScoreColor } from '../common/utils/get_cvsscore_color'; interface CVSScoreBadgeProps { score: float; - version: CVSSVersion; - color: string; + version?: string; } interface SeverityStatusBadgeProps { - status: SeverityStatus; - color: string | undefined; + status: string; + score: float; } interface ExploitsStatusBadgeProps { totalExploits: number; } -export const CVSScoreBadge = ({ score, color, version }: CVSScoreBadgeProps) => { - const versionDisplay = `v${version.split('.')[0]}`; +export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => { + const color = getCvsScoreColor(score); + const versionDisplay = version ? `v${version.split('.')[0]}` : null; return ( } `} > - {score} -
- {versionDisplay} + {versionDisplay && ( + <> + {score} +
+ {versionDisplay} + + )}
); }; -export const SeverityStatusBadge = ({ color, status }: SeverityStatusBadgeProps) => { - const xxsFontSize = useEuiFontSize('xxs').fontSize; - const { euiTheme } = useEuiTheme(); +export const SeverityStatusBadge = ({ score, status }: SeverityStatusBadgeProps) => { + const color = getCvsScoreColor(score); return ( - - {color && ( - - )} - - {i18n.translate('xpack.csp.vulnerabilityBadges.severityStatusBadge', { - defaultMessage: '{status}', - values: { status }, - })} - - + <> + + {status} + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx index 3c6b51f881989..aa1a81319e88f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.test.tsx @@ -17,11 +17,11 @@ import { getFindingsQuery } from './use_latest_findings'; import { encodeQuery } from '../../../common/navigation/query_utils'; import { useLocation } from 'react-router-dom'; import { buildEsQuery } from '@kbn/es-query'; -import { getPaginationQuery } from '../utils/utils'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { getPaginationQuery } from '../../../common/hooks/use_cloud_posture_table/utils'; jest.mock('../../../common/api/use_latest_findings_data_view'); jest.mock('../../../common/api/use_cis_kubernetes_integration'); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx index 872ed90b02763..6d8234da7556c 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/latest_findings_container.tsx @@ -8,30 +8,23 @@ import React, { useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { Evaluation } from '../../../../common/types'; -import type { FindingsBaseProps } from '../types'; +import type { FindingsBaseProps, FindingsBaseURLQuery } from '../../../common/types'; import { FindingsTable } from './latest_findings_table'; import { FindingsSearchBar } from '../layout/findings_search_bar'; import * as TEST_SUBJECTS from '../test_subjects'; import { useLatestFindings } from './use_latest_findings'; import type { FindingsGroupByNoneQuery } from './use_latest_findings'; -import type { FindingsBaseURLQuery } from '../types'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; -import { - getFindingsPageSizeInfo, - getFilters, - getPaginationTableParams, - useBaseEsQuery, - usePersistedQuery, -} from '../utils/utils'; +import { getFindingsPageSizeInfo, getFilters } from '../utils/utils'; import { LimitedResultsBar } from '../layout/findings_layout'; import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; -import { useUrlQuery } from '../../../common/hooks/use_url_query'; import { usePageSlice } from '../../../common/hooks/use_page_slice'; -import { usePageSize } from '../../../common/hooks/use_page_size'; import { ErrorCallout } from '../layout/error_callout'; -import { useLimitProperties } from '../utils/get_limit_properties'; +import { useLimitProperties } from '../../../common/utils/get_limit_properties'; import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; import { CspFinding } from '../../../../common/schemas/csp_finding'; +import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table'; +import { getPaginationTableParams } from '../../../common/hooks/use_cloud_posture_table/utils'; export const getDefaultQuery = ({ query, @@ -46,35 +39,38 @@ export const getDefaultQuery = ({ }); export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { - const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); - const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); - const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY); - - /** - * Page URL query to ES query - */ - const baseEsQuery = useBaseEsQuery({ + const { + pageIndex, + query, + sort, + queryError, + pageSize, + setTableOptions, + urlQuery, + setUrlQuery, + filters, + } = useCloudPostureTable({ dataView, - filters: urlQuery.filters, - query: urlQuery.query, + defaultQuery: getDefaultQuery, + paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, }); /** * Page ES query result */ const findingsGroupByNone = useLatestFindings({ - query: baseEsQuery.query, - sort: urlQuery.sort, - enabled: !baseEsQuery.error, + query, + sort, + enabled: !queryError, }); - const slicedPage = usePageSlice(findingsGroupByNone.data?.page, urlQuery.pageIndex, pageSize); + const slicedPage = usePageSlice(findingsGroupByNone.data?.page, pageIndex, pageSize); - const error = findingsGroupByNone.error || baseEsQuery.error; + const error = findingsGroupByNone.error || queryError; const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ total: findingsGroupByNone.data?.total, - pageIndex: urlQuery.pageIndex, + pageIndex, pageSize, }); @@ -82,7 +78,7 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { setUrlQuery({ pageIndex: 0, filters: getFilters({ - filters: urlQuery.filters, + filters, dataView, field: 'result.evaluation', value: evaluation, @@ -95,7 +91,7 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { const pagination = getPaginationTableParams({ pageSize, - pageIndex: urlQuery.pageIndex, + pageIndex, totalItemCount: limitedTotalItemCount, }); @@ -123,10 +119,10 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { const newFindingIndex = nextFindingIndex % pageSize; // if the finding is not in the current page, we need to change the page - const pageIndex = Math.floor(nextFindingIndex / pageSize); + const flyoutPageIndex = Math.floor(nextFindingIndex / pageSize); setUrlQuery({ - pageIndex, + pageIndex: flyoutPageIndex, findingIndex: newFindingIndex, }); }, @@ -136,9 +132,9 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { return (
{ - setUrlQuery({ ...query, pageIndex: 0 }); + dataView={dataView!} + setQuery={(newQuery) => { + setUrlQuery({ ...newQuery, pageIndex: 0 }); }} loading={findingsGroupByNone.isFetching} /> @@ -162,7 +158,7 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { passed: findingsGroupByNone.data.count.passed, failed: findingsGroupByNone.data.count.failed, ...getFindingsPageSizeInfo({ - pageIndex: urlQuery.pageIndex, + pageIndex, pageSize, currentPageSize: slicedPage.length, }), @@ -179,20 +175,14 @@ export const LatestFindingsContainer = ({ dataView }: FindingsBaseProps) => { items={slicedPage} pagination={pagination} sorting={{ - sort: { field: urlQuery.sort.field, direction: urlQuery.sort.direction }, - }} - setTableOptions={({ page, sort }) => { - setPageSize(page.size); - setUrlQuery({ - sort, - pageIndex: page.index, - }); + sort: { field: sort.field, direction: sort.direction }, }} + setTableOptions={setTableOptions} onAddFilter={(field, value, negate) => setUrlQuery({ pageIndex: 0, filters: getFilters({ - filters: urlQuery.filters, + filters, dataView, field, value, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts index a10758da90c77..00aa0d817e955 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings/use_latest_findings.ts @@ -8,18 +8,15 @@ import { useQuery } from '@tanstack/react-query'; import { number } from 'io-ts'; import { lastValueFrom } from 'rxjs'; import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; -import type { CoreStart } from '@kbn/core/public'; import type { Pagination } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { i18n } from '@kbn/i18n'; import { CspFinding } from '../../../../common/schemas/csp_finding'; -import { extractErrorMessage } from '../../../../common/utils/helpers'; -import type { Sort } from '../types'; import { useKibana } from '../../../common/hooks/use_kibana'; -import type { FindingsBaseEsQuery } from '../types'; +import type { Sort, FindingsBaseEsQuery } from '../../../common/types'; import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; +import { showErrorToast } from '../../../common/utils/show_error_toast'; interface UseFindingsOptions extends FindingsBaseEsQuery { sort: Sort; @@ -40,19 +37,6 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } -const SEARCH_FAILED_TEXT = i18n.translate( - 'xpack.csp.findings.findingsErrorToast.searchFailedTitle', - { defaultMessage: 'Search failed' } -); - -export const showErrorToast = ( - toasts: CoreStart['notifications']['toasts'], - error: unknown -): void => { - if (error instanceof Error) toasts.addError(error, { title: SEARCH_FAILED_TEXT }); - else toasts.addDanger(extractErrorMessage(error, SEARCH_FAILED_TEXT)); -}; - export const getFindingsQuery = ({ query, sort }: UseFindingsOptions) => ({ index: CSP_LATEST_FINDINGS_DATA_VIEW, query, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx index d3640f4f4d256..a3a43b8bdca79 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_container.tsx @@ -13,19 +13,10 @@ import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import type { Evaluation } from '../../../../common/types'; import { FindingsSearchBar } from '../layout/findings_search_bar'; import * as TEST_SUBJECTS from '../test_subjects'; -import { useUrlQuery } from '../../../common/hooks/use_url_query'; import { usePageSlice } from '../../../common/hooks/use_page_slice'; -import { usePageSize } from '../../../common/hooks/use_page_size'; -import type { FindingsBaseProps, FindingsBaseURLQuery } from '../types'; import { FindingsByResourceQuery, useFindingsByResource } from './use_findings_by_resource'; import { FindingsByResourceTable } from './findings_by_resource_table'; -import { - getFindingsPageSizeInfo, - getFilters, - getPaginationTableParams, - useBaseEsQuery, - usePersistedQuery, -} from '../utils/utils'; +import { getFindingsPageSizeInfo, getFilters } from '../utils/utils'; import { LimitedResultsBar } from '../layout/findings_layout'; import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; import { findingsNavigation } from '../../../common/navigation/constants'; @@ -33,7 +24,10 @@ import { ResourceFindings } from './resource_findings/resource_findings_containe import { ErrorCallout } from '../layout/error_callout'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../common/constants'; -import { useLimitProperties } from '../utils/get_limit_properties'; +import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../common/types'; +import { useCloudPostureTable } from '../../../common/hooks/use_cloud_posture_table'; +import { useLimitProperties } from '../../../common/utils/get_limit_properties'; +import { getPaginationTableParams } from '../../../common/hooks/use_cloud_posture_table/utils'; const getDefaultQuery = ({ query, @@ -68,29 +62,23 @@ export const FindingsByResourceContainer = ({ dataView }: FindingsBaseProps) => ); const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { - const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); - const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); - const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY); - - /** - * Page URL query to ES query - */ - const baseEsQuery = useBaseEsQuery({ - dataView, - filters: urlQuery.filters, - query: urlQuery.query, - }); + const { queryError, query, pageSize, setTableOptions, urlQuery, setUrlQuery } = + useCloudPostureTable({ + dataView, + defaultQuery: getDefaultQuery, + paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, + }); /** * Page ES query result */ const findingsGroupByResource = useFindingsByResource({ sortDirection: urlQuery.sortDirection, - query: baseEsQuery.query, - enabled: !baseEsQuery.error, + query, + enabled: !queryError, }); - const error = findingsGroupByResource.error || baseEsQuery.error; + const error = findingsGroupByResource.error || queryError; const slicedPage = usePageSlice(findingsGroupByResource.data?.page, urlQuery.pageIndex, pageSize); @@ -116,9 +104,9 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { return (
{ - setUrlQuery({ ...query, pageIndex: 0 }); + dataView={dataView!} + setQuery={(newQuery) => { + setUrlQuery({ ...newQuery, pageIndex: 0 }); }} loading={findingsGroupByResource.isFetching} /> @@ -158,13 +146,7 @@ const LatestFindingsByResource = ({ dataView }: FindingsBaseProps) => { pageIndex: urlQuery.pageIndex, totalItemCount: limitedTotalItemCount, })} - setTableOptions={({ sort, page }) => { - setPageSize(page.size); - setUrlQuery({ - sortDirection: sort?.direction, - pageIndex: page.index, - }); - }} + setTableOptions={setTableOptions} sorting={{ sort: { field: 'compliance_score', direction: urlQuery.sortDirection }, }} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx index 75ef4b8e4cbae..ec6d3e0438ac5 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React from 'react'; import { render, screen, within } from '@testing-library/react'; import * as TEST_SUBJECTS from '../test_subjects'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx index a9598b228f3ae..1a7bbbaf34192 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/resource_findings_container.tsx @@ -23,23 +23,17 @@ import * as TEST_SUBJECTS from '../../test_subjects'; import { LimitedResultsBar, PageTitle, PageTitleText } from '../../layout/findings_layout'; import { findingsNavigation } from '../../../../common/navigation/constants'; import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings'; -import { useUrlQuery } from '../../../../common/hooks/use_url_query'; import { usePageSlice } from '../../../../common/hooks/use_page_slice'; -import { usePageSize } from '../../../../common/hooks/use_page_size'; -import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../types'; -import { - getFindingsPageSizeInfo, - getFilters, - getPaginationTableParams, - useBaseEsQuery, - usePersistedQuery, -} from '../../utils/utils'; +import { getFindingsPageSizeInfo, getFilters } from '../../utils/utils'; import { ResourceFindingsTable } from './resource_findings_table'; import { FindingsSearchBar } from '../../layout/findings_search_bar'; import { ErrorCallout } from '../../layout/error_callout'; import { FindingsDistributionBar } from '../../layout/findings_distribution_bar'; import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../../../common/constants'; -import { useLimitProperties } from '../../utils/get_limit_properties'; +import type { FindingsBaseURLQuery, FindingsBaseProps } from '../../../../common/types'; +import { useCloudPostureTable } from '../../../../common/hooks/use_cloud_posture_table'; +import { useLimitProperties } from '../../../../common/utils/get_limit_properties'; +import { getPaginationTableParams } from '../../../../common/hooks/use_cloud_posture_table/utils'; const getDefaultQuery = ({ query, @@ -101,30 +95,23 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { const params = useParams<{ resourceId: string }>(); const decodedResourceId = decodeURIComponent(params.resourceId); - const getPersistedDefaultQuery = usePersistedQuery(getDefaultQuery); - const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); - const { pageSize, setPageSize } = usePageSize(LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY); - - /** - * Page URL query to ES query - */ - const baseEsQuery = useBaseEsQuery({ - dataView, - filters: urlQuery.filters, - query: urlQuery.query, - }); + const { pageIndex, sort, queryError, pageSize, setTableOptions, urlQuery, setUrlQuery } = + useCloudPostureTable({ + dataView, + defaultQuery: getDefaultQuery, + paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, + }); /** * Page ES query result */ const resourceFindings = useResourceFindings({ - sort: urlQuery.sort, - query: baseEsQuery.query, + sort, resourceId: decodedResourceId, - enabled: !baseEsQuery.error, + enabled: !queryError, }); - const error = resourceFindings.error || baseEsQuery.error; + const error = resourceFindings.error || queryError; const slicedPage = usePageSlice(resourceFindings.data?.page, urlQuery.pageIndex, pageSize); @@ -151,7 +138,7 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { const pagination = getPaginationTableParams({ pageSize, - pageIndex: urlQuery.pageIndex, + pageIndex, totalItemCount: limitedTotalItemCount, }); @@ -179,10 +166,10 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { const newFindingIndex = nextFindingIndex % pageSize; // if the finding is not in the current page, we need to change the page - const pageIndex = Math.floor(nextFindingIndex / pageSize); + const flyoutPageIndex = Math.floor(nextFindingIndex / pageSize); setUrlQuery({ - pageIndex, + pageIndex: flyoutPageIndex, findingIndex: newFindingIndex, }); }, @@ -192,9 +179,9 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { return (
{ - setUrlQuery({ ...query, pageIndex: 0 }); + dataView={dataView!} + setQuery={(newQuery) => { + setUrlQuery({ ...newQuery, pageIndex: 0 }); }} loading={resourceFindings.isFetching} /> @@ -266,10 +253,7 @@ export const ResourceFindings = ({ dataView }: FindingsBaseProps) => { sorting={{ sort: { field: urlQuery.sort.field, direction: urlQuery.sort.direction }, }} - setTableOptions={({ page, sort }) => { - setPageSize(page.size); - setUrlQuery({ pageIndex: page.index, sort }); - }} + setTableOptions={setTableOptions} onAddFilter={(field, value, negate) => setUrlQuery({ pageIndex: 0, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts index 17520e68bceb9..b5e9c19f6de2a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/resource_findings/use_resource_findings.ts @@ -13,10 +13,10 @@ import { number } from 'io-ts'; import { CspFinding } from '../../../../../common/schemas/csp_finding'; import { getAggregationCount, getFindingsCountAggQuery } from '../../utils/utils'; import { useKibana } from '../../../../common/hooks/use_kibana'; -import { showErrorToast } from '../../latest_findings/use_latest_findings'; -import type { FindingsBaseEsQuery, Sort } from '../../types'; +import type { FindingsBaseEsQuery, Sort } from '../../../../common/types'; import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../../common/constants'; import { MAX_FINDINGS_TO_LOAD } from '../../../../common/constants'; +import { showErrorToast } from '../../../../common/utils/show_error_toast'; interface UseResourceFindingsOptions extends FindingsBaseEsQuery { resourceId: string; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts index 446eb7ab6c281..8589d40f5e069 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/latest_findings_by_resource/use_findings_by_resource.ts @@ -21,8 +21,8 @@ import { import { getBelongsToRuntimeMapping } from '../../../../common/runtime_mappings/get_belongs_to_runtime_mapping'; import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; import { useKibana } from '../../../common/hooks/use_kibana'; -import { showErrorToast } from '../latest_findings/use_latest_findings'; -import type { FindingsBaseEsQuery, Sort } from '../types'; +import { showErrorToast } from '../../../common/utils/show_error_toast'; +import type { FindingsBaseEsQuery, Sort } from '../../../common/types'; import { getAggregationCount, getFindingsCountAggQuery } from '../utils/utils'; import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_group_by_selector.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_group_by_selector.tsx index d997dce43b2b5..007c56662b248 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_group_by_selector.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_group_by_selector.tsx @@ -10,7 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; -import type { FindingsGroupByKind } from '../types'; +import type { FindingsGroupByKind } from '../../../common/types'; import { findingsNavigation } from '../../../common/navigation/constants'; import * as TEST_SUBJECTS from '../test_subjects'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx index 479e56a545090..df8c0df546957 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/layout/findings_search_bar.tsx @@ -12,7 +12,7 @@ import type { DataView } from '@kbn/data-plugin/common'; import { i18n } from '@kbn/i18n'; import type { Filter } from '@kbn/es-query'; import { SecuritySolutionContext } from '../../../application/security_solution_context'; -import type { FindingsBaseURLQuery } from '../types'; +import type { FindingsBaseURLQuery } from '../../../common/types'; import type { CspClientPluginStartDeps } from '../../../types'; import { PLUGIN_NAME } from '../../../../common'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_filters.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_filters.ts index dd8593ad026a1..200b8777f8cfc 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_filters.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/get_filters.ts @@ -14,7 +14,7 @@ import { FilterCompareOptions, } from '@kbn/es-query'; import type { Serializable } from '@kbn/utility-types'; -import type { FindingsBaseProps } from '../types'; +import type { FindingsBaseProps } from '../../../common/types'; const compareOptions: FilterCompareOptions = { negate: false, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/utils.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/utils.ts index 32cb914b446e2..3e0277d7cd4c1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/utils.ts @@ -5,108 +5,11 @@ * 2.0. */ -import { buildEsQuery, type Query } from '@kbn/es-query'; -import type { EuiBasicTableProps, EuiThemeComputed, Pagination } from '@elastic/eui'; -import { useCallback, useEffect, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; import type { estypes } from '@elastic/elasticsearch'; -import type { FindingsBaseProps, FindingsBaseURLQuery } from '../types'; -import { useKibana } from '../../../common/hooks/use_kibana'; +import { EuiThemeComputed } from '@elastic/eui'; import type { CspFinding } from '../../../../common/schemas/csp_finding'; export { getFilters } from './get_filters'; -const getBaseQuery = ({ dataView, query, filters }: FindingsBaseURLQuery & FindingsBaseProps) => { - try { - return { - query: buildEsQuery(dataView, query, filters), // will throw for malformed query - }; - } catch (error) { - return { - query: undefined, - error: error instanceof Error ? error : new Error('Unknown Error'), - }; - } -}; - -type TablePagination = NonNullable['pagination']>; - -export const getPaginationTableParams = ( - params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, - pageSizeOptions = [10, 25, 100], - showPerPageOptions = true -): Required => ({ - ...params, - pageSizeOptions, - showPerPageOptions, -}); - -export const usePersistedQuery = (getter: ({ filters, query }: FindingsBaseURLQuery) => T) => { - const { - data: { - query: { filterManager, queryString }, - }, - } = useKibana().services; - - return useCallback( - () => - getter({ - filters: filterManager.getAppFilters(), - query: queryString.getQuery() as Query, - }), - [getter, filterManager, queryString] - ); -}; - -export const getPaginationQuery = ({ - pageIndex, - pageSize, -}: Pick) => ({ - from: pageIndex * pageSize, - size: pageSize, -}); - -export const useBaseEsQuery = ({ - dataView, - filters, - query, -}: FindingsBaseURLQuery & FindingsBaseProps) => { - const { - notifications: { toasts }, - data: { - query: { filterManager, queryString }, - }, - } = useKibana().services; - - const baseEsQuery = useMemo( - () => getBaseQuery({ dataView, filters, query }), - [dataView, filters, query] - ); - - /** - * Sync filters with the URL query - */ - useEffect(() => { - filterManager.setAppFilters(filters); - queryString.setQuery(query); - }, [filters, filterManager, queryString, query]); - - const handleMalformedQueryError = () => { - const error = baseEsQuery.error; - if (error) { - toasts.addError(error, { - title: i18n.translate('xpack.csp.findings.search.queryErrorToastMessage', { - defaultMessage: 'Query Error', - }), - toastLifeTimeMs: 1000 * 5, - }); - } - }; - - useEffect(handleMalformedQueryError, [baseEsQuery.error, toasts]); - - return baseEsQuery; -}; - export const getFindingsPageSizeInfo = ({ currentPageSize, pageIndex, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx new file mode 100644 index 0000000000000..798f66fa0dfca --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/hooks/use_latest_vulnerabilities.tsx @@ -0,0 +1,56 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { lastValueFrom } from 'rxjs'; +import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; +import { number } from 'io-ts'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants'; +import { useKibana } from '../../../common/hooks/use_kibana'; +import { showErrorToast } from '../../../common/utils/show_error_toast'; +import { MAX_FINDINGS_TO_LOAD } from '../../../common/constants'; +type LatestFindingsRequest = IKibanaSearchRequest; +type LatestFindingsResponse = IKibanaSearchResponse>; + +interface FindingsAggs { + count: estypes.AggregationsMultiBucketAggregateBase; +} + +export const getFindingsQuery = ({ query }: any) => ({ + index: LATEST_VULNERABILITIES_INDEX_PATTERN, + query, + size: MAX_FINDINGS_TO_LOAD, +}); + +export const useLatestVulnerabilities = (options: any) => { + const { + data, + notifications: { toasts }, + } = useKibana().services; + return useQuery( + [LATEST_VULNERABILITIES_INDEX_PATTERN, { params: options }], + async () => { + const { + rawResponse: { hits }, + } = await lastValueFrom( + data.search.search({ + params: getFindingsQuery(options), + }) + ); + + return { + page: hits.hits.map((hit) => hit._source!), + total: number.is(hits.total) ? hits.total : 0, + }; + }, + { + enabled: options.enabled, + keepPreviousData: true, + onError: (err: Error) => showErrorToast(toasts, err), + } + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts new file mode 100644 index 0000000000000..d7810fde2a1fb --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/types.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface VulnerabilityRecord { + '@timestamp': string; + resource?: { + id: string; + name: string; + }; + event: { + type: string[]; + category: string[]; + created: string; + id: string; + kind: string; + sequence: number; + outcome: string; + }; + vulnerability: { + score: { + version: string; + impact: number; + base: number; + }; + cwe: string[]; + id: string; + title: string; + reference: string; + severity: string; + cvss: { + nvd: { + V3Vector: string; + V3Score: number; + }; + redhat?: { + V3Vector: string; + V3Score: number; + }; + ghsa?: { + V3Vector: string; + V3Score: number; + }; + }; + data_source: { + ID: string; + Name: string; + URL: string; + }; + enumeration: string; + description: string; + classification: string; + scanner: { + vendor: string; + }; + package: { + version: string; + name: string; + fixed_version: string; + }; + }; + ecs: { + version: string; + }; + host: { + os: { + name: string; + kernel: string; + codename: string; + type: string; + platform: string; + version: string; + family: string; + }; + id: string; + name: string; + containerized: boolean; + ip: string[]; + mac: string[]; + hostname: string; + architecture: string; + }; + agent: { + ephemeral_id: string; + id: string; + name: string; + type: string; + version: string; + }; + cloud: { + image: { + id: string; + }; + provider: string; + instance: { + id: string; + }; + machine: { + type: string; + }; + region: string; + availability_zone: string; + service: { + name: string; + }; + account: { + id: string; + }; + }; + cloudbeat: { + version: string; + commit_sha: string; + commit_time: string; + }; +} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts new file mode 100644 index 0000000000000..ff2171aa76013 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts @@ -0,0 +1,87 @@ +/* + * 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 { EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const vulnerabilitiesColumns = { + actions: 'actions', + vulnerability: 'vulnerability', + cvss: 'cvss', + resource: 'resource', + severity: 'severity', + package_version: 'package_version', + fix_version: 'fix_version', +}; + +const defaultColumnProps = () => ({ + isExpandable: false, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + }, +}); + +export const getVulnerabilitiesColumnsGrid = (): EuiDataGridColumn[] => { + return [ + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.actions, + initialWidth: 40, + display: [], + actions: false, + isSortable: false, + isResizable: false, + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.vulnerability, + displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.vulnerability', { + defaultMessage: 'Vulnerability', + }), + initialWidth: 150, + isResizable: false, + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.cvss, + displayAsText: 'CVSS', + initialWidth: 84, + isResizable: false, + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.resource, + displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.resource', { + defaultMessage: 'Resource', + }), + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.severity, + displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.severity', { + defaultMessage: 'Severity', + }), + initialWidth: 100, + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.package_version, + displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.packageAndVersion', { + defaultMessage: 'Package and Version', + }), + }, + { + ...defaultColumnProps(), + id: vulnerabilitiesColumns.fix_version, + displayAsText: i18n.translate('xpack.csp.vulnerabilityTable.column.fixVersion', { + defaultMessage: 'Fix Version', + }), + }, + ]; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index bc9d4f50c9990..0609d86497928 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -4,17 +4,237 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import React from 'react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiDataGrid, + EuiDataGridCellValueElementProps, + EuiEmptyPrompt, + EuiLoadingSpinner, + EuiSpacer, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY } from '../../common/constants'; +import { useCloudPostureTable } from '../../common/hooks/use_cloud_posture_table'; +import { useLatestVulnerabilities } from './hooks/use_latest_vulnerabilities'; +import { VulnerabilityRecord } from './types'; +import { getVulnerabilitiesColumnsGrid, vulnerabilitiesColumns } from './utils'; +import { LATEST_VULNERABILITIES_INDEX_PATTERN } from '../../../common/constants'; +import { ErrorCallout } from '../configurations/layout/error_callout'; +import { FindingsSearchBar } from '../configurations/layout/findings_search_bar'; +import { useFilteredDataView } from '../../common/api/use_filtered_data_view'; +import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges'; + +const getDefaultQuery = ({ query, filters }: any): any => ({ + query, + filters, + sort: [{ id: vulnerabilitiesColumns.cvss, direction: 'desc' }], + pageIndex: 0, +}); export const Vulnerabilities = () => { + const { data, isLoading, error } = useFilteredDataView(LATEST_VULNERABILITIES_INDEX_PATTERN); + + if (error) { + return ; + } + if (isLoading || !data) { + return ; + } + + return ; +}; + +const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { + const { + pageIndex, + query, + sort, + queryError, + pageSize, + onChangeItemsPerPage, + onChangePage, + onSort, + setUrlQuery, + } = useCloudPostureTable({ + dataView, + defaultQuery: getDefaultQuery, + paginationLocalStorageKey: LOCAL_STORAGE_PAGE_SIZE_FINDINGS_KEY, + }); + const { euiTheme } = useEuiTheme(); + + const { data, isLoading } = useLatestVulnerabilities({ + query, + sort, + enabled: !queryError, + }); + + const renderCellValue = useMemo(() => { + return ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { + const vulnerabilityRow = data?.page[rowIndex] as VulnerabilityRecord; + + if (!vulnerabilityRow) return null; + if (!vulnerabilityRow.vulnerability?.id) return null; + + if (columnId === vulnerabilitiesColumns.actions) { + return ( + { + alert(`Flyout id ${vulnerabilityRow.vulnerability.id}`); + }} + /> + ); + } + if (columnId === vulnerabilitiesColumns.vulnerability) { + return vulnerabilityRow.vulnerability.id || null; + } + if (columnId === vulnerabilitiesColumns.cvss) { + if (!vulnerabilityRow.vulnerability.score?.base) { + return null; + } + return ( + + ); + } + if (columnId === vulnerabilitiesColumns.resource) { + return vulnerabilityRow.resource?.name || null; + } + if (columnId === vulnerabilitiesColumns.severity) { + if ( + !vulnerabilityRow.vulnerability.score?.base || + !vulnerabilityRow.vulnerability.severity + ) { + return null; + } + return ( + + ); + } + if (columnId === vulnerabilitiesColumns.package_version) { + return ( + <> + {vulnerabilityRow.vulnerability?.package?.name}{' '} + {vulnerabilityRow.vulnerability?.package?.version} + + ); + } + if (columnId === vulnerabilitiesColumns.fix_version) { + return ( + <> + {vulnerabilityRow.vulnerability.package?.name}{' '} + {vulnerabilityRow.vulnerability.package?.fixed_version} + + ); + } + }; + }, [data?.page]); + + const error = queryError || null; + + if (error) { + return ; + } + if (isLoading || !data?.page) { + return ; + } + + const columns = getVulnerabilitiesColumnsGrid(); + return ( <> - - { + setUrlQuery({ ...newQuery, pageIndex: 0 }); + }} + loading={isLoading} /> + + {!isLoading && data.page.length === 0 ? ( + + + + } + /> + ) : ( + id), + setVisibleColumns: () => {}, + }} + rowCount={data?.total} + toolbarVisibility={{ + showColumnSelector: false, + showDisplaySelector: false, + showKeyboardShortcuts: false, + additionalControls: { + left: { + prepend: ( + + {i18n.translate('xpack.csp.vulnerabilities.totalVulnerabilities', { + defaultMessage: + '{total, plural, one {# Vulnerability} other {# Vulnerabilities}}', + values: { total: data?.total }, + })} + + ), + }, + }, + }} + gridStyle={{ + border: 'horizontal', + cellPadding: 'l', + stripes: false, + rowHover: 'none', + header: 'underline', + }} + renderCellValue={renderCellValue} + inMemory={{ level: 'sorting' }} + sorting={{ columns: sort, onSort }} + pagination={{ + pageIndex, + pageSize, + pageSizeOptions: [10, 25, 100], + onChangeItemsPerPage, + onChangePage, + }} + /> + )} ); }; From b431bccfc99769a9bea3996ffd09468623fc3874 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Wed, 12 Apr 2023 18:25:44 +0200 Subject: [PATCH 05/12] [Enterprise Search] Fix logic for hidden indices (#154833) Fixes a minor bug in the logic to determine whether an index is hidden. --- .../server/lib/indices/utils/get_index_data.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/server/lib/indices/utils/get_index_data.ts b/x-pack/plugins/enterprise_search/server/lib/indices/utils/get_index_data.ts index eb106436aa9d6..1c8bb88c3e4bf 100644 --- a/x-pack/plugins/enterprise_search/server/lib/indices/utils/get_index_data.ts +++ b/x-pack/plugins/enterprise_search/server/lib/indices/utils/get_index_data.ts @@ -90,7 +90,7 @@ export const getIndexDataMapper = (totalIndexData: TotalIndexData) => { }; function isHidden(index: IndicesIndexState): boolean { - return Boolean(index.settings?.index?.hidden) || index.settings?.index?.hidden === 'true'; + return index.settings?.index?.hidden === true || index.settings?.index?.hidden === 'true'; } export const getIndexData = async ( From f2a4dbfcadb33d251a26e353cb484285a6417386 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 12 Apr 2023 18:31:56 +0200 Subject: [PATCH 06/12] Add Create Rule from SLO List item (#154800) --- .../pages/slos/components/slo_list_item.tsx | 35 +++++++++++++++++++ .../public/pages/slos/slos.test.tsx | 27 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx index e2d5339958684..6c8e44bec189d 100644 --- a/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx +++ b/x-pack/plugins/observability/public/pages/slos/components/slo_list_item.tsx @@ -25,6 +25,7 @@ import { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts'; import { useCapabilities } from '../../../hooks/slo/use_capabilities'; import { useKibana } from '../../../utils/kibana_react'; import { useCloneSlo } from '../../../hooks/slo/use_clone_slo'; +import { useGetFilteredRuleTypes } from '../../../hooks/use_get_filtered_rule_types'; import { SloSummary } from './slo_summary'; import { SloDeleteConfirmationModal } from './slo_delete_confirmation_modal'; import { SloBadges } from './badges/slo_badges'; @@ -32,6 +33,8 @@ import { transformSloResponseToCreateSloInput, transformValuesToCreateSLOInput, } from '../../slo_edit/helpers/process_slo_form_values'; +import { SLO_BURN_RATE_RULE_ID } from '../../../../common/constants'; +import { sloFeatureId } from '../../../../common'; import { paths } from '../../../config/paths'; export interface SloListItemProps { @@ -50,13 +53,17 @@ export function SloListItem({ const { application: { navigateToUrl }, http: { basePath }, + triggersActionsUi: { getAddRuleFlyout: AddRuleFlyout }, } = useKibana().services; const { hasWriteCapabilities } = useCapabilities(); + const filteredRuleTypes = useGetFilteredRuleTypes(); + const { mutate: cloneSlo } = useCloneSlo(); const isDeletingSlo = Boolean(useIsMutating(['deleteSlo', slo.id])); const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + const [isAddRuleFlyoutOpen, setIsAddRuleFlyoutOpen] = useState(false); const [isDeleteConfirmationModalOpen, setDeleteConfirmationModalOpen] = useState(false); const handleClickActions = () => { @@ -71,6 +78,11 @@ export function SloListItem({ navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id))); }; + const handleCreateRule = () => { + setIsActionsPopoverOpen(false); + setIsAddRuleFlyoutOpen(true); + }; + const handleClone = () => { const newSlo = transformValuesToCreateSLOInput( transformSloResponseToCreateSloInput({ ...slo, name: `[Copy] ${slo.name}` })! @@ -169,6 +181,17 @@ export function SloListItem({ defaultMessage: 'Edit', })} , + + {i18n.translate('xpack.observability.slo.slo.item.actions.createRule', { + defaultMessage: 'Create Alert rule', + })} + , ) : null} + + {isAddRuleFlyoutOpen ? ( + { + setIsAddRuleFlyoutOpen(false); + }} + /> + ) : null} ); } diff --git a/x-pack/plugins/observability/public/pages/slos/slos.test.tsx b/x-pack/plugins/observability/public/pages/slos/slos.test.tsx index debacf68a5446..a3e01f0b00458 100644 --- a/x-pack/plugins/observability/public/pages/slos/slos.test.tsx +++ b/x-pack/plugins/observability/public/pages/slos/slos.test.tsx @@ -60,6 +60,7 @@ useDeleteSloMock.mockReturnValue({ mutate: mockDeleteSlo }); const mockNavigate = jest.fn(); const mockAddSuccess = jest.fn(); const mockAddError = jest.fn(); +const mockGetAddRuleFlyout = jest.fn().mockReturnValue(() =>
Add rule flyout
); const mockKibana = () => { useKibanaMock.mockReturnValue({ @@ -77,6 +78,7 @@ const mockKibana = () => { addError: mockAddError, }, }, + triggersActionsUi: { getAddRuleFlyout: mockGetAddRuleFlyout }, uiSettings: { get: (settings: string) => { if (settings === 'dateFormat') return 'YYYY-MM-DD'; @@ -199,6 +201,31 @@ describe('SLOs Page', () => { expect(mockNavigate).toBeCalled(); }); + it('allows creating a new rule for an SLO', async () => { + useFetchSloListMock.mockReturnValue({ isLoading: false, sloList }); + + useFetchHistoricalSummaryMock.mockReturnValue({ + isLoading: false, + sloHistoricalSummaryResponse: historicalSummaryData, + }); + + await act(async () => { + render(); + }); + + screen.getAllByLabelText('Actions').at(0)?.click(); + + await waitForEuiPopoverOpen(); + + const button = screen.getByTestId('sloActionsCreateRule'); + + expect(button).toBeTruthy(); + + button.click(); + + expect(mockGetAddRuleFlyout).toBeCalled(); + }); + it('allows deleting an SLO', async () => { useFetchSloListMock.mockReturnValue({ isLoading: false, sloList }); From b8f490a728cd8d78afb857b1f955964dfd31e65f Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 12 Apr 2023 11:08:51 -0600 Subject: [PATCH 07/12] [Dashboard] [Controls] Prevent control group reload on output change (#154763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/elastic/kibana/issues/154146 ## Summary This PR fixes a race condition for chained options list controls where making changes to the output of the first control in the chain would sometimes cause the chained controls to be stuck in an infinite loading state. (Thanks to @ThomThomson for helping me narrow this down). Basically, before this, **any** change to the control group output (for example, by making a selection in an options list control) would cause the dashboard to `forceRefresh` the entire control group: https://github.com/elastic/kibana/blob/682e2ed6aee8bef8127cfa1cba44dff2eb681c31/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts#L174-L185 So, imagine you have a dashboard with two chained controls: control A and control B. Making a selection in control A will cause the following chain of events: 1. Make a selection in control A 2. Control B refetches its suggestions because hierarchical chaining is turned on 3. At "the same time" (more-or-less), the subscription above fires due to step 1 changing the control group output, and so the dashboard forces a refresh of the control group 4. This causes both control A and control B to refetch their suggestions unnecessarily, due to the `reload` logic of `options_list_embeddable.tsx`. https://github.com/elastic/kibana/blob/682e2ed6aee8bef8127cfa1cba44dff2eb681c31/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx#L417-L421 Because control B has now had two of the "same" requests sent to the server, this caused a race condition where, depending on how fast things completed and the state of the caching (which is cleared on `reload` but is **not** cleared when a new selection is made), sometimes **both** requests would get aborted and so the following early return would prevent control B from setting its loading state to `false` (i.e. it would get stuck in an infinite loading state): https://github.com/elastic/kibana/blob/682e2ed6aee8bef8127cfa1cba44dff2eb681c31/src/plugins/controls/public/options_list/embeddable/options_list_embeddable.tsx#L329-L337 This PR prevents this race condition by only refreshing the **dashboard's children** (i.e. the visualization embeddables) and not the control group itself when the control group's output changes - as an extra benefit, this makes the control group more efficient than it was before since the unnecessary refresh is no longer slowing things down 💃 ### Flaky Test Runner ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../components/options_list_popover_footer.tsx | 6 +++++- .../embeddable/dashboard_container.tsx | 4 ++-- .../controls/dashboard_control_group_integration.ts | 2 +- .../controls/control_group_chaining.ts | 7 +++---- .../page_objects/dashboard_page_controls.ts | 13 ++++++++----- test/functional/services/common/test_subjects.ts | 5 +++-- 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx b/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx index d04e389f8ec99..966cc56d98044 100644 --- a/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx +++ b/src/plugins/controls/public/options_list/components/options_list_popover_footer.tsx @@ -54,7 +54,11 @@ export const OptionsListPopoverFooter = ({ isLoading }: { isLoading: boolean }) > {isLoading && (
- +
)}
(); diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts index c961a93e770f6..2f0f8626528b9 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/integrations/controls/dashboard_control_group_integration.ts @@ -181,7 +181,7 @@ function startSyncingDashboardControlGroup(this: DashboardContainer) { ), skip(1) // skip first filter output because it will have been applied in initialize ) - .subscribe(() => this.forceRefresh()) + .subscribe(() => this.forceRefresh(false)) // we should not reload the control group when the control group output changes - otherwise, performance is severely impacted ); subscriptions.add( diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts index d77e3862c2dad..e85d3a43fbc3f 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - // FLAKY: https://github.com/elastic/kibana/issues/154146 - describe.skip('Dashboard control group hierarchical chaining', () => { + describe('Dashboard control group hierarchical chaining', () => { const newDocuments: Array<{ index: string; id: string }> = []; let controlIds: string[]; @@ -44,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { /* start by adding some incomplete data so that we can test `exists` query */ await common.navigateToApp('console'); - await console.collapseHelp(); + await console.closeHelpIfExists(); await console.clearTextArea(); await addDocument( 'animals-cats-2018-01-01', @@ -88,7 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await common.navigateToApp('console'); - await console.collapseHelp(); + await console.closeHelpIfExists(); await console.clearTextArea(); for (const { index, id } of newDocuments) { await console.enterRequest(`\nDELETE /${index}/_doc/${id}`); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index ff4ac5b67f804..8234699225653 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -448,12 +448,14 @@ export class DashboardPageControls extends FtrService { this.log.debug(`searching for ${search} in options list`); await this.optionsListPopoverAssertOpen(); await this.testSubjects.setValue(`optionsList-control-search-input`, search); + await this.optionsListPopoverWaitForLoading(); } public async optionsListPopoverClearSearch() { this.log.debug(`clearing search from options list`); await this.optionsListPopoverAssertOpen(); await this.find.clickByCssSelector('.euiFormControlLayoutClearButton'); + await this.optionsListPopoverWaitForLoading(); } public async optionsListPopoverSetSort(sort: OptionsListSortingType) { @@ -464,14 +466,14 @@ export class DashboardPageControls extends FtrService { await this.retry.try(async () => { await this.testSubjects.existOrFail('optionsListControl__sortingOptionsPopover'); }); - await this.testSubjects.click(`optionsList__sortOrder_${sort.direction}`); await this.testSubjects.click(`optionsList__sortBy_${sort.by}`); - await this.testSubjects.click('optionsListControl__sortingOptionsButton'); await this.retry.try(async () => { await this.testSubjects.missingOrFail(`optionsListControl__sortingOptionsPopover`); }); + + await this.optionsListPopoverWaitForLoading(); } public async optionsListPopoverSelectExists() { @@ -484,7 +486,6 @@ export class DashboardPageControls extends FtrService { public async optionsListPopoverSelectOption(availableOption: string) { this.log.debug(`selecting ${availableOption} from options list`); await this.optionsListPopoverSearchForOption(availableOption); - await this.optionsListPopoverWaitForLoading(); await this.retry.try(async () => { await this.testSubjects.existOrFail(`optionsList-control-selection-${availableOption}`); @@ -492,7 +493,6 @@ export class DashboardPageControls extends FtrService { }); await this.optionsListPopoverClearSearch(); - await this.optionsListPopoverWaitForLoading(); } public async optionsListPopoverClearSelections() { @@ -516,7 +516,10 @@ export class DashboardPageControls extends FtrService { public async optionsListWaitForLoading(controlId: string) { this.log.debug(`wait for ${controlId} to load`); - await this.testSubjects.waitForEnabled(`optionsList-control-${controlId}`); + const enabled = await this.testSubjects.waitForEnabled(`optionsList-control-${controlId}`); + if (!enabled) { + throw new Error(`${controlId} did not finish loading within the given time limit`); + } } public async optionsListPopoverWaitForLoading() { diff --git a/test/functional/services/common/test_subjects.ts b/test/functional/services/common/test_subjects.ts index 994d4549c4fbb..e54a1caa08d26 100644 --- a/test/functional/services/common/test_subjects.ts +++ b/test/functional/services/common/test_subjects.ts @@ -360,11 +360,12 @@ export class TestSubjects extends FtrService { await this.findService.waitForElementHidden(element, timeout); } - public async waitForEnabled(selector: string, timeout: number = this.TRY_TIME): Promise { - await this.retry.tryForTime(timeout, async () => { + public async waitForEnabled(selector: string, timeout: number = this.TRY_TIME): Promise { + const success = await this.retry.tryForTime(timeout, async () => { const element = await this.find(selector); return (await element.isDisplayed()) && (await element.isEnabled()); }); + return success; } public getCssSelector(selector: string): string { From fb0c1bf4007fa1447283d85960a9c4998f93a63f Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Wed, 12 Apr 2023 20:45:39 +0200 Subject: [PATCH 08/12] Remove Beta badge from the SLO item in the sidebar Nav (#154854) --- x-pack/plugins/observability/public/plugin.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 2f3c19dfac38d..d497e02d533e1 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -289,7 +289,6 @@ export class Plugin app: observabilityAppId, label: link.title, path: link.path ?? '', - isBetaFeature: link.id === 'slos' ? true : false, })); const sections = [ From b07743b2692d70fdd7cdcd58f4e8b5cc43e9e7a8 Mon Sep 17 00:00:00 2001 From: Lola Date: Wed, 12 Apr 2023 14:55:21 -0400 Subject: [PATCH 09/12] [Cloud Posture] onboarding prompts for vul_mgmt (#154118) ## Summary This PR adds onboarding prompts for when vulnerability management integration is not installed and when vulnerability management is installed then show scanning empty prompt. A `` component checks for `vuln_mgmt` status. If the status is `not-deployed` or `indexing` then show The packageNoInstalledRender component will listen for the current tab and renders on `useLocation().pathname`. If `currentTab` is 'vulnerabilities' then we show ``. If `currentTab` is `configurations` then show `` Installed Prompt Screen Shot 2023-03-30 at 3 45 35 PM Scanning Envioronment Screen Shot 2023-03-30 at 3 45 20 PM --------- Co-authored-by: Kfir Peled --- .../common/constants.ts | 7 +- .../cloud_security_posture/common/types.ts | 1 + .../api/use_latest_findings_data_view.ts | 13 +- .../public/common/api/use_setup_status_api.ts | 6 +- .../navigation/use_csp_integration_link.ts | 4 +- .../public/common/utils/get_cpm_status.tsx | 53 ---- .../components/cloud_posture_page.test.tsx | 82 +----- .../public/components/cloud_posture_page.tsx | 104 +------- .../public/components/no_findings_states.tsx | 87 ++++++- .../components/no_vulnerabilities_states.tsx | 227 +++++++++++++++++ .../public/components/test_subjects.ts | 9 + .../compliance_dashboard.test.tsx | 19 +- .../compliance_dashboard.tsx | 233 ++++++++++-------- .../configurations/configurations.test.tsx | 30 +++ .../pages/configurations/configurations.tsx | 14 +- .../findings_flyout/overview_tab.tsx | 3 +- .../pages/vulnerabilities/vulnerabilities.tsx | 5 + .../vulnerabilities/vulnerabilties.test.tsx | 202 +++++++++++++++ .../server/routes/status/status.test.ts | 102 ++++---- .../server/routes/status/status.ts | 53 ++-- .../page_objects/findings_page.ts | 49 +++- .../pages/findings.ts | 5 + 22 files changed, 853 insertions(+), 455 deletions(-) delete mode 100644 x-pack/plugins/cloud_security_posture/public/common/utils/get_cpm_status.tsx create mode 100644 x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx create mode 100644 x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index c1adc5c806334..69b99bc9d58cb 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -12,7 +12,7 @@ export const STATS_ROUTE_PATH = '/internal/cloud_security_posture/stats/{policy_ export const BENCHMARKS_ROUTE_PATH = '/internal/cloud_security_posture/benchmarks'; export const CLOUD_SECURITY_POSTURE_PACKAGE_NAME = 'cloud_security_posture'; - +// TODO: REMOVE CSP_LATEST_FINDINGS_DATA_VIEW and replace it with LATEST_FINDINGS_INDEX_PATTERN export const CSP_LATEST_FINDINGS_DATA_VIEW = 'logs-cloud_security_posture.findings_latest-*'; export const FINDINGS_INDEX_NAME = 'logs-cloud_security_posture.findings'; @@ -96,4 +96,7 @@ export const POSTURE_TYPES: { [x: string]: PostureTypes } = { [CSPM_POLICY_TEMPLATE]: CSPM_POLICY_TEMPLATE, [VULN_MGMT_POLICY_TEMPLATE]: VULN_MGMT_POLICY_TEMPLATE, [POSTURE_TYPE_ALL]: POSTURE_TYPE_ALL, -} as const; +}; + +export const VULNERABILITIES = 'vulnerabilities'; +export const CONFIGURATIONS = 'configurations'; diff --git a/x-pack/plugins/cloud_security_posture/common/types.ts b/x-pack/plugins/cloud_security_posture/common/types.ts index 8bd1c86eac3f6..6d2a64eb3a01d 100644 --- a/x-pack/plugins/cloud_security_posture/common/types.ts +++ b/x-pack/plugins/cloud_security_posture/common/types.ts @@ -87,6 +87,7 @@ export interface BaseCspSetupStatus { kspm: BaseCspSetupBothPolicy; vuln_mgmt: BaseCspSetupBothPolicy; isPluginInitialized: boolean; + installedPackageVersion?: string | undefined; } export type CspSetupStatus = BaseCspSetupStatus; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts index e40a4c5a8e8c2..2ab22ff4dd092 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_latest_findings_data_view.ts @@ -8,25 +8,24 @@ import { useQuery } from '@tanstack/react-query'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-plugin/common'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants'; import { CspClientPluginStartDeps } from '../../types'; /** * TODO: use perfected kibana data views */ -export const useLatestFindingsDataView = () => { +export const useLatestFindingsDataView = (dataView: string) => { const { data: { dataViews }, } = useKibana().services; const findDataView = async (): Promise => { - const dataView = (await dataViews.find(CSP_LATEST_FINDINGS_DATA_VIEW))?.[0]; - if (!dataView) { - throw new Error('Findings data view not found'); + const dataViewObj = (await dataViews.find(dataView))?.[0]; + if (!dataViewObj) { + throw new Error(`Data view not found [Name: {${dataView}}]`); } - return dataView; + return dataViewObj; }; - return useQuery(['latest_findings_data_view'], findDataView); + return useQuery([`useDataView-${dataView}`], findDataView); }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/api/use_setup_status_api.ts b/x-pack/plugins/cloud_security_posture/public/common/api/use_setup_status_api.ts index 31edc058dec52..99e277b30bb54 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/api/use_setup_status_api.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/api/use_setup_status_api.ts @@ -12,9 +12,9 @@ import { STATUS_ROUTE_PATH } from '../../../common/constants'; const getCspSetupStatusQueryKey = 'csp_status_key'; -export const useCspSetupStatusApi = ({ - options, -}: { options?: UseQueryOptions } = {}) => { +export const useCspSetupStatusApi = ( + options?: UseQueryOptions +) => { const { http } = useKibana().services; return useQuery( [getCspSetupStatusQueryKey], diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_integration_link.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_integration_link.ts index 8d6e0f6c38583..fb255a9545ad4 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_integration_link.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_integration_link.ts @@ -6,12 +6,12 @@ */ import { pagePathGetters, pkgKeyFromPackageInfo } from '@kbn/fleet-plugin/public'; -import type { PosturePolicyTemplate } from '../../../common/types'; +import type { CloudSecurityPolicyTemplate } from '../../../common/types'; import { useCisKubernetesIntegration } from '../api/use_cis_kubernetes_integration'; import { useKibana } from '../hooks/use_kibana'; export const useCspIntegrationLink = ( - policyTemplate: PosturePolicyTemplate + policyTemplate: CloudSecurityPolicyTemplate ): string | undefined => { const { http } = useKibana().services; const cisIntegration = useCisKubernetesIntegration(); diff --git a/x-pack/plugins/cloud_security_posture/public/common/utils/get_cpm_status.tsx b/x-pack/plugins/cloud_security_posture/public/common/utils/get_cpm_status.tsx deleted file mode 100644 index cf40e4d797fa2..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/common/utils/get_cpm_status.tsx +++ /dev/null @@ -1,53 +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 { CspSetupStatus } from '../../../common/types'; - -// Cloud Posture Management Status -export const getCpmStatus = (cpmStatusData: CspSetupStatus | undefined) => { - // if has findings in any of the integrations. - const hasFindings = - cpmStatusData?.indicesDetails[0].status === 'not-empty' || - cpmStatusData?.kspm.status === 'indexed' || - cpmStatusData?.cspm.status === 'indexed'; - - // kspm - const hasKspmFindings = - cpmStatusData?.kspm?.status === 'indexed' || - cpmStatusData?.indicesDetails[0].status === 'not-empty'; - - // cspm - const hasCspmFindings = - cpmStatusData?.cspm?.status === 'indexed' || - cpmStatusData?.indicesDetails[0].status === 'not-empty'; - - const isKspmInstalled = cpmStatusData?.kspm?.status !== 'not-installed'; - const isCspmInstalled = cpmStatusData?.cspm?.status !== 'not-installed'; - const isKspmPrivileged = cpmStatusData?.kspm?.status !== 'unprivileged'; - const isCspmPrivileged = cpmStatusData?.cspm?.status !== 'unprivileged'; - - const isCspmIntegrationInstalled = isCspmInstalled && isCspmPrivileged; - const isKspmIntegrationInstalled = isKspmInstalled && isKspmPrivileged; - - const isEmptyData = - cpmStatusData?.kspm?.status === 'not-installed' && - cpmStatusData?.cspm?.status === 'not-installed' && - cpmStatusData?.indicesDetails[0].status === 'empty'; - - return { - hasFindings, - hasKspmFindings, - hasCspmFindings, - isCspmInstalled, - isKspmInstalled, - isKspmPrivileged, - isCspmPrivileged, - isCspmIntegrationInstalled, - isKspmIntegrationInstalled, - isEmptyData, - }; -}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.test.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.test.tsx index 3785690532442..0304695994088 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.test.tsx @@ -23,8 +23,6 @@ import React, { ComponentProps } from 'react'; import { UseQueryResult } from '@tanstack/react-query'; import { CloudPosturePage } from './cloud_posture_page'; import { NoDataPage } from '@kbn/kibana-react-plugin/public'; -import { useCspSetupStatusApi } from '../common/api/use_setup_status_api'; -import { useCspIntegrationLink } from '../common/navigation/use_csp_integration_link'; const chance = new Chance(); @@ -35,19 +33,6 @@ jest.mock('../common/navigation/use_csp_integration_link'); describe('', () => { beforeEach(() => { jest.resetAllMocks(); - (useCspSetupStatusApi as jest.Mock).mockImplementation(() => - createReactQueryResponse({ - status: 'success', - data: { - cspm: { status: 'indexed' }, - kspm: { status: 'indexed' }, - indicesDetails: [ - { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, - { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, - ], - }, - }) - ); (useSubscriptionStatus as jest.Mock).mockImplementation(() => createReactQueryResponse({ @@ -58,7 +43,9 @@ describe('', () => { }); const renderCloudPosturePage = ( - props: ComponentProps = { children: null } + props: ComponentProps = { + children: null, + } ) => { const mockCore = coreMock.createStart(); @@ -147,69 +134,6 @@ describe('', () => { expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); }); - it('renders integrations installation prompt if integration is not installed', () => { - (useCspSetupStatusApi as jest.Mock).mockImplementation(() => - createReactQueryResponse({ - status: 'success', - data: { - kspm: { status: 'not-installed' }, - cspm: { status: 'not-installed' }, - indicesDetails: [ - { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, - { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, - ], - }, - }) - ); - (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); - - const children = chance.sentence(); - renderCloudPosturePage({ children }); - - expect(screen.getByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).toBeInTheDocument(); - expect(screen.queryByText(children)).not.toBeInTheDocument(); - expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); - }); - - it('renders default loading state when the integration query is loading', () => { - (useCspSetupStatusApi as jest.Mock).mockImplementation( - () => - createReactQueryResponse({ - status: 'loading', - }) as unknown as UseQueryResult - ); - - const children = chance.sentence(); - renderCloudPosturePage({ children }); - - expect(screen.getByTestId(LOADING_STATE_TEST_SUBJECT)).toBeInTheDocument(); - expect(screen.queryByText(children)).not.toBeInTheDocument(); - expect(screen.queryByTestId(ERROR_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); - }); - - it('renders default error state when the integration query has an error', () => { - (useCspSetupStatusApi as jest.Mock).mockImplementation( - () => - createReactQueryResponse({ - status: 'error', - error: new Error('error'), - }) as unknown as UseQueryResult - ); - - const children = chance.sentence(); - renderCloudPosturePage({ children }); - - expect(screen.getByTestId(ERROR_STATE_TEST_SUBJECT)).toBeInTheDocument(); - expect(screen.queryByTestId(LOADING_STATE_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByTestId(SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT)).not.toBeInTheDocument(); - expect(screen.queryByText(children)).not.toBeInTheDocument(); - expect(screen.queryByTestId(PACKAGE_NOT_INSTALLED_TEST_SUBJECT)).not.toBeInTheDocument(); - }); - it('renders default loading text when query isLoading', () => { const query = createReactQueryResponse({ status: 'loading', diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.tsx index 24d0c15db32ca..7fb386ae16160 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_posture_page.tsx @@ -7,34 +7,22 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import type { UseQueryResult } from '@tanstack/react-query'; -import { - EuiButton, - EuiEmptyPrompt, - EuiImage, - EuiFlexGroup, - EuiFlexItem, - EuiLink, -} from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { NoDataPage, NoDataPageProps } from '@kbn/kibana-react-plugin/public'; import { css } from '@emotion/react'; -import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '../../common/constants'; import { SubscriptionNotAllowed } from './subscription_not_allowed'; import { useSubscriptionStatus } from '../common/hooks/use_subscription_status'; import { FullSizeCenteredPage } from './full_size_centered_page'; -import { useCspSetupStatusApi } from '../common/api/use_setup_status_api'; import { CspLoadingState } from './csp_loading_state'; -import { useCspIntegrationLink } from '../common/navigation/use_csp_integration_link'; - -import noDataIllustration from '../assets/illustrations/no_data_illustration.svg'; -import { cspIntegrationDocsNavigation } from '../common/navigation/constants'; -import { getCpmStatus } from '../common/utils/get_cpm_status'; export const LOADING_STATE_TEST_SUBJECT = 'cloud_posture_page_loading'; export const ERROR_STATE_TEST_SUBJECT = 'cloud_posture_page_error'; export const PACKAGE_NOT_INSTALLED_TEST_SUBJECT = 'cloud_posture_page_package_not_installed'; export const CSPM_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT = 'cloud_posture_page_cspm_not_installed'; export const KSPM_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT = 'cloud_posture_page_kspm_not_installed'; +export const VULN_MGMT_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT = + 'cloud_posture_page_vuln_mgmt_not_installed'; export const DEFAULT_NO_DATA_TEST_SUBJECT = 'cloud_posture_page_no_data'; export const SUBSCRIPTION_NOT_ALLOWED_TEST_SUBJECT = 'cloud_posture_page_subscription_not_allowed'; @@ -104,71 +92,6 @@ export const CspNoDataPage = ({ ); }; -const packageNotInstalledRenderer = ({ - kspmIntegrationLink, - cspmIntegrationLink, -}: { - kspmIntegrationLink?: string; - cspmIntegrationLink?: string; -}) => { - return ( - - } - title={ -

- -

- } - layout="horizontal" - color="plain" - body={ -

- - - - ), - }} - /> -

- } - actions={ - - - - - - - - - - - - - } - /> -
- ); -}; - const defaultLoadingRenderer = () => ( ({ noDataRenderer = defaultNoDataRenderer, }: CloudPosturePageProps) => { const subscriptionStatus = useSubscriptionStatus(); - const { data: getSetupStatus, isLoading, isError, error } = useCspSetupStatusApi(); - const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE); - const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE); - const { isEmptyData, hasFindings } = getCpmStatus(getSetupStatus); const render = () => { if (subscriptionStatus.isError) { @@ -269,23 +188,6 @@ export const CloudPosturePage = ({ return subscriptionNotAllowedRenderer(); } - if (isError) { - return defaultErrorRenderer(error); - } - - if (isLoading) { - return defaultLoadingRenderer(); - } - - /* Checks if its a completely new user which means no integration has been installed and no latest findings default index has been found */ - if (isEmptyData) { - return packageNotInstalledRenderer({ kspmIntegrationLink, cspmIntegrationLink }); - } - - if (!hasFindings) { - return children; - } - if (!query) { return children; } diff --git a/x-pack/plugins/cloud_security_posture/public/components/no_findings_states.tsx b/x-pack/plugins/cloud_security_posture/public/components/no_findings_states.tsx index 0dd428417d51b..daed1b794ff63 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/no_findings_states.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/no_findings_states.tsx @@ -13,17 +13,24 @@ import { EuiIcon, EuiMarkdownFormat, EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiImage, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; +import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '../../common/constants'; import { FullSizeCenteredPage } from './full_size_centered_page'; import { useCspBenchmarkIntegrations } from '../pages/benchmarks/use_csp_benchmark_integrations'; import { useCISIntegrationPoliciesLink } from '../common/navigation/use_navigate_to_cis_integration_policies'; import { NO_FINDINGS_STATUS_TEST_SUBJ } from './test_subjects'; -import { CloudPosturePage } from './cloud_posture_page'; +import { CloudPosturePage, PACKAGE_NOT_INSTALLED_TEST_SUBJECT } from './cloud_posture_page'; import { useCspSetupStatusApi } from '../common/api/use_setup_status_api'; import type { IndexDetails, PostureTypes } from '../../common/types'; +import { cspIntegrationDocsNavigation } from '../common/navigation/constants'; +import noDataIllustration from '../assets/illustrations/no_data_illustration.svg'; +import { useCspIntegrationLink } from '../common/navigation/use_csp_integration_link'; const REFETCH_INTERVAL_MS = 20000; @@ -172,18 +179,87 @@ const Unprivileged = ({ unprivilegedIndices }: { unprivilegedIndices: string[] } /> ); +const ConfigurationFindingsInstalledEmptyPrompt = ({ + kspmIntegrationLink, + cspmIntegrationLink, +}: { + kspmIntegrationLink?: string; + cspmIntegrationLink?: string; +}) => { + return ( + } + title={ +

+ +

+ } + layout="horizontal" + color="plain" + body={ +

+ + + + ), + }} + /> +

+ } + actions={ + + + + + + + + + + + + + } + /> + ); +}; + /** * This component will return the render states based on cloud posture setup status API * since 'not-installed' is being checked globally by CloudPosturePage and 'indexed' is the pass condition, those states won't be handled here * */ export const NoFindingsStates = ({ posturetype }: { posturetype: PostureTypes }) => { const getSetupStatus = useCspSetupStatusApi({ - options: { refetchInterval: REFETCH_INTERVAL_MS }, + refetchInterval: REFETCH_INTERVAL_MS, }); const statusKspm = getSetupStatus.data?.kspm?.status; const statusCspm = getSetupStatus.data?.cspm?.status; const indicesStatus = getSetupStatus.data?.indicesDetails; const status = posturetype === 'cspm' ? statusCspm : statusKspm; + const showConfigurationInstallPrompt = + getSetupStatus.data?.kspm?.status === 'not-installed' && + getSetupStatus.data?.cspm?.status === 'not-installed'; + const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE); + const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE); + const unprivilegedIndices = indicesStatus && indicesStatus @@ -196,6 +272,13 @@ export const NoFindingsStates = ({ posturetype }: { posturetype: PostureTypes }) if (status === 'index-timeout') return ; // agent added, index timeout has passed if (status === 'unprivileged') return ; // user has no privileges for our indices + if (showConfigurationInstallPrompt) + return ( + + ); }; return ( diff --git a/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx b/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx new file mode 100644 index 0000000000000..9106f1486b3f7 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/components/no_vulnerabilities_states.tsx @@ -0,0 +1,227 @@ +/* + * 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 { + EuiLoadingLogo, + EuiEmptyPrompt, + EuiIcon, + EuiMarkdownFormat, + EuiLink, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiImage, +} from '@elastic/eui'; +import { FormattedHTMLMessage, FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { VULN_MGMT_POLICY_TEMPLATE } from '../../common/constants'; +import { FullSizeCenteredPage } from './full_size_centered_page'; +import { + CloudPosturePage, + VULN_MGMT_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT, +} from './cloud_posture_page'; +import { useCspSetupStatusApi } from '../common/api/use_setup_status_api'; +import type { IndexDetails } from '../../common/types'; +import { NO_VULNERABILITIES_STATUS_TEST_SUBJ } from './test_subjects'; +import noDataIllustration from '../assets/illustrations/no_data_illustration.svg'; +import { useCspIntegrationLink } from '../common/navigation/use_csp_integration_link'; + +const REFETCH_INTERVAL_MS = 20000; + +const ScanningVulnerabilitiesEmptyPrompt = () => ( + } + title={ +

+ +

+ } + body={ +

+ +

+ } + /> +); + +const VulnerabilitiesFindingsInstalledEmptyPrompt = ({ + vulnMgmtIntegrationLink, +}: { + vulnMgmtIntegrationLink?: string; +}) => { + return ( + } + title={ +

+ +

+ } + layout="horizontal" + color="plain" + body={ +

+ + + + ), + }} + /> +

+ } + actions={ + + + + + + + + } + /> + ); +}; + +const IndexTimeout = () => ( + } + title={ +

+ +

+ } + body={ +

+ + + + ), + }} + /> +

+ } + /> +); + +const Unprivileged = ({ unprivilegedIndices }: { unprivilegedIndices: string[] }) => ( + } + title={ +

+ +

+ } + body={ +

+ +

+ } + footer={ + `\n- \`${idx}\``) + } + /> + } + /> +); + +/** + * This component will return the render states based on cloud posture setup status API + * since 'not-installed' is being checked globally by CloudPosturePage and 'indexed' is the pass condition, those states won't be handled here + * */ +export const NoVulnerabilitiesStates = () => { + const getSetupStatus = useCspSetupStatusApi({ + refetchInterval: REFETCH_INTERVAL_MS, + }); + const vulnMgmtIntegrationLink = useCspIntegrationLink(VULN_MGMT_POLICY_TEMPLATE); + + const status = getSetupStatus.data?.vuln_mgmt?.status; + const indicesStatus = getSetupStatus.data?.indicesDetails; + const unprivilegedIndices = + indicesStatus && + indicesStatus + .filter((idxDetails) => idxDetails.status === 'unprivileged') + .map((idxDetails: IndexDetails) => idxDetails.index) + .sort((a, b) => a.localeCompare(b)); + + const render = () => { + if (status === 'not-deployed' || status === 'indexing') + return ; // integration installed, but no agents added + if (status === 'index-timeout') return ; // agent added, index timeout has passed + if (status === 'not-installed') + return ( + + ); + if (status === 'unprivileged') + return ; // user has no privileges for our indices + }; + + return ( + + {render()} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts index 95f3a83de9708..7b818971d597f 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts +++ b/x-pack/plugins/cloud_security_posture/public/components/test_subjects.ts @@ -19,3 +19,12 @@ export const NO_FINDINGS_STATUS_TEST_SUBJ = { UNPRIVILEGED: 'status-api-unprivileged', NO_FINDINGS: 'no-findings-found', }; + +export const NO_VULNERABILITIES_STATUS_TEST_SUBJ = { + SCANNING_VULNERABILITIES: 'scanning-vulnerabilities-empty-prompt', + UNPRIVILEGED: 'status-api-vuln-mgmt-unprivileged', + INDEX_TIMEOUT: 'status-api-vuln-mgmt-index-timeout', + NO_VULNERABILITIES: 'no-vulnerabilities-vuln-mgmt-found', +}; + +export const VULNERABILITIES_CONTAINER_TEST_SUBJ = 'vulnerabilities_container'; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx index 277edb4909dac..6ef3a7e4f4fe1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.test.tsx @@ -42,7 +42,7 @@ describe('', () => { (useCspSetupStatusApi as jest.Mock).mockImplementation(() => createReactQueryResponse({ status: 'success', - data: { status: 'indexed' }, + data: { status: 'indexed', installedPackageVersion: '1.2.13' }, }) ); @@ -94,6 +94,7 @@ describe('', () => { data: { kspm: { status: 'not-deployed', healthyAgents: 0, installedPackagePolicies: 1 }, cspm: { status: 'not-deployed', healthyAgents: 0, installedPackagePolicies: 1 }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -131,6 +132,7 @@ describe('', () => { data: { kspm: { status: 'indexing', healthyAgents: 1, installedPackagePolicies: 1 }, cspm: { status: 'indexing', healthyAgents: 1, installedPackagePolicies: 1 }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -168,6 +170,7 @@ describe('', () => { data: { kspm: { status: 'index-timeout', healthyAgents: 1, installedPackagePolicies: 1 }, cspm: { status: 'index-timeout', healthyAgents: 1, installedPackagePolicies: 1 }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -205,6 +208,7 @@ describe('', () => { data: { kspm: { status: 'unprivileged', healthyAgents: 1, installedPackagePolicies: 1 }, cspm: { status: 'unprivileged', healthyAgents: 1, installedPackagePolicies: 1 }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -242,6 +246,7 @@ describe('', () => { data: { kspm: { status: 'indexed' }, cspm: { status: 'indexed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -280,6 +285,7 @@ describe('', () => { data: { kspm: { status: 'indexed' }, cspm: { status: 'not-installed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -318,6 +324,7 @@ describe('', () => { status: 'success', data: { cspm: { status: 'indexed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -356,6 +363,7 @@ describe('', () => { status: 'success', data: { cspm: { status: 'indexed', healthyAgents: 0, installedPackagePolicies: 1 }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -395,6 +403,7 @@ describe('', () => { data: { kspm: { status: 'indexed', healthyAgents: 0, installedPackagePolicies: 1 }, cspm: { status: 'not-installed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -434,6 +443,7 @@ describe('', () => { data: { cspm: { status: 'indexed' }, kspm: { status: 'indexed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -473,6 +483,7 @@ describe('', () => { data: { cspm: { status: 'indexed' }, kspm: { status: 'indexed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'not-empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'not-empty' }, @@ -512,6 +523,7 @@ describe('', () => { data: { kspm: { status: 'not-deployed', healthyAgents: 0, installedPackagePolicies: 1 }, cspm: { status: 'not-installed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -527,7 +539,7 @@ describe('', () => { (useCspmStatsApi as jest.Mock).mockImplementation(() => ({ isSuccess: true, isLoading: false, - data: undefined, + data: { stats: { totalFindings: 0 } }, })); renderComplianceDashboardPage(); @@ -553,6 +565,7 @@ describe('', () => { data: { cspm: { status: 'not-deployed' }, kspm: { status: 'not-installed' }, + installedPackageVersion: '1.2.13', indicesDetails: [ { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, @@ -568,7 +581,7 @@ describe('', () => { (useKspmStatsApi as jest.Mock).mockImplementation(() => ({ isSuccess: true, isLoading: false, - data: undefined, + data: { stats: { totalFindings: 0 } }, })); renderComplianceDashboardPage(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx index 6939601dfd745..6c75891fc62af 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx @@ -12,7 +12,11 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { NO_FINDINGS_STATUS_TEST_SUBJ } from '../../components/test_subjects'; import { useCspIntegrationLink } from '../../common/navigation/use_csp_integration_link'; -import type { PosturePolicyTemplate, ComplianceDashboardData } from '../../../common/types'; +import type { + PosturePolicyTemplate, + ComplianceDashboardData, + BaseCspSetupStatus, +} from '../../../common/types'; import { CloudPosturePageTitle } from '../../components/cloud_posture_page_title'; import { CloudPosturePage, @@ -35,7 +39,6 @@ import { SummarySection } from './dashboard_sections/summary_section'; import { BenchmarksSection } from './dashboard_sections/benchmarks_section'; import { CSPM_POLICY_TEMPLATE, KSPM_POLICY_TEMPLATE } from '../../../common/constants'; import { cspIntegrationDocsNavigation } from '../../common/navigation/constants'; -import { getCpmStatus } from '../../common/utils/get_cpm_status'; const noDataOptions: Record< PosturePolicyTemplate, @@ -177,144 +180,157 @@ const IntegrationPostureDashboard = ({ ); }; +const getDefaultTab = ( + pluginStatus?: BaseCspSetupStatus, + cspmStats?: ComplianceDashboardData, + kspmStats?: ComplianceDashboardData +) => { + const cspmTotalFindings = cspmStats?.stats.totalFindings; + const kspmTotalFindings = kspmStats?.stats.totalFindings; + const installedPolicyTemplatesCspm = pluginStatus?.cspm?.status; + const installedPolicyTemplatesKspm = pluginStatus?.kspm?.status; + let preferredDashboard = CSPM_POLICY_TEMPLATE; + + // cspm has findings + if (!!cspmTotalFindings) { + preferredDashboard = CSPM_POLICY_TEMPLATE; + } + // kspm has findings + else if (!!kspmTotalFindings) { + preferredDashboard = KSPM_POLICY_TEMPLATE; + } + // cspm is installed + else if ( + installedPolicyTemplatesCspm !== 'unprivileged' && + installedPolicyTemplatesCspm !== 'not-installed' + ) { + preferredDashboard = CSPM_POLICY_TEMPLATE; + } + // kspm is installed + else if ( + installedPolicyTemplatesKspm !== 'unprivileged' && + installedPolicyTemplatesKspm !== 'not-installed' + ) { + preferredDashboard = KSPM_POLICY_TEMPLATE; + } + + return preferredDashboard; +}; + export const ComplianceDashboard = () => { const [selectedTab, setSelectedTab] = useState(CSPM_POLICY_TEMPLATE); const { data: getSetupStatus } = useCspSetupStatusApi(); - const { - hasKspmFindings, - hasCspmFindings, - isKspmInstalled, - isCspmInstalled, - isCspmIntegrationInstalled, - isKspmIntegrationInstalled, - } = getCpmStatus(getSetupStatus); - const cspmIntegrationLink = useCspIntegrationLink(CSPM_POLICY_TEMPLATE); const kspmIntegrationLink = useCspIntegrationLink(KSPM_POLICY_TEMPLATE); + const isCloudSecurityPostureInstalled = !!getSetupStatus?.installedPackageVersion; const getCspmDashboardData = useCspmStatsApi({ - enabled: hasCspmFindings, + enabled: isCloudSecurityPostureInstalled, }); const getKspmDashboardData = useKspmStatsApi({ - enabled: hasKspmFindings, + enabled: isCloudSecurityPostureInstalled, }); useEffect(() => { - const cspmTotalFindings = getCspmDashboardData.data?.stats.totalFindings; - const kspmTotalFindings = getKspmDashboardData.data?.stats.totalFindings; - const installedPolicyTemplatesCspm = getSetupStatus?.cspm?.status; - const installedPolicyTemplatesKspm = getSetupStatus?.kspm?.status; - let preferredDashboard = CSPM_POLICY_TEMPLATE; - - // cspm has findings - if (!!cspmTotalFindings) { - preferredDashboard = CSPM_POLICY_TEMPLATE; - } - // kspm has findings - else if (!!kspmTotalFindings) { - preferredDashboard = KSPM_POLICY_TEMPLATE; - } - // cspm is installed - else if ( - installedPolicyTemplatesCspm !== 'unprivileged' && - installedPolicyTemplatesCspm !== 'not-installed' - ) { - preferredDashboard = CSPM_POLICY_TEMPLATE; - } - // kspm is installed - else if ( - installedPolicyTemplatesKspm !== 'unprivileged' && - installedPolicyTemplatesKspm !== 'not-installed' - ) { - preferredDashboard = KSPM_POLICY_TEMPLATE; - } + const preferredDashboard = getDefaultTab( + getSetupStatus, + getCspmDashboardData.data, + getKspmDashboardData.data + ); setSelectedTab(preferredDashboard); }, [ + getCspmDashboardData.data, getCspmDashboardData.data?.stats.totalFindings, + getKspmDashboardData.data, getKspmDashboardData.data?.stats.totalFindings, + getSetupStatus, getSetupStatus?.cspm?.status, getSetupStatus?.kspm?.status, ]); const tabs = useMemo( - () => [ - { - label: i18n.translate('xpack.csp.dashboardTabs.cloudTab.tabTitle', { - defaultMessage: 'Cloud', - }), - 'data-test-subj': CLOUD_DASHBOARD_TAB, - isSelected: selectedTab === CSPM_POLICY_TEMPLATE, - onClick: () => setSelectedTab(CSPM_POLICY_TEMPLATE), - content: ( - <> - {hasCspmFindings || !isCspmInstalled ? ( - -
- -
-
- ) : ( - - )} - - ), - }, - { - label: i18n.translate('xpack.csp.dashboardTabs.kubernetesTab.tabTitle', { - defaultMessage: 'Kubernetes', - }), - 'data-test-subj': KUBERNETES_DASHBOARD_TAB, - isSelected: selectedTab === KSPM_POLICY_TEMPLATE, - onClick: () => setSelectedTab(KSPM_POLICY_TEMPLATE), - content: ( - <> - {hasKspmFindings || !isKspmInstalled ? ( - -
- -
-
- ) : ( - - )} - - ), - }, - ], + () => + isCloudSecurityPostureInstalled + ? [ + { + label: i18n.translate('xpack.csp.dashboardTabs.cloudTab.tabTitle', { + defaultMessage: 'Cloud', + }), + 'data-test-subj': CLOUD_DASHBOARD_TAB, + isSelected: selectedTab === CSPM_POLICY_TEMPLATE, + onClick: () => setSelectedTab(CSPM_POLICY_TEMPLATE), + content: ( + <> + {isCloudSecurityPostureInstalled && + (getSetupStatus?.cspm?.status === 'indexed' || + getSetupStatus?.cspm?.status === 'not-installed') ? ( + +
+ +
+
+ ) : ( + + )} + + ), + }, + { + label: i18n.translate('xpack.csp.dashboardTabs.kubernetesTab.tabTitle', { + defaultMessage: 'Kubernetes', + }), + 'data-test-subj': KUBERNETES_DASHBOARD_TAB, + isSelected: selectedTab === KSPM_POLICY_TEMPLATE, + onClick: () => setSelectedTab(KSPM_POLICY_TEMPLATE), + content: ( + <> + {isCloudSecurityPostureInstalled && + (getSetupStatus?.kspm?.status === 'indexed' || + getSetupStatus?.kspm?.status === 'not-installed') ? ( + +
+ +
+
+ ) : ( + + )} + + ), + }, + ] + : [], [ cspmIntegrationLink, getCspmDashboardData, getKspmDashboardData, kspmIntegrationLink, selectedTab, - hasCspmFindings, - hasKspmFindings, - isKspmIntegrationInstalled, - isCspmIntegrationInstalled, - isCspmInstalled, - isKspmInstalled, + isCloudSecurityPostureInstalled, + getSetupStatus?.cspm?.status, + getSetupStatus?.kspm?.status, ] ); return ( - + { `} > {tabs.find((t) => t.isSelected)?.content} + {!isCloudSecurityPostureInstalled && }
); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx index dc2b8818ff442..ef2fdc1ca83b8 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.test.tsx @@ -29,6 +29,7 @@ import { render } from '@testing-library/react'; import { expectIdsInDoc } from '../../test/utils'; import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { PACKAGE_NOT_INSTALLED_TEST_SUBJECT } from '../../components/cloud_posture_page'; jest.mock('../../common/api/use_latest_findings_data_view'); jest.mock('../../common/api/use_setup_status_api'); @@ -222,4 +223,33 @@ describe('', () => { ], }); }); + + it('renders integrations installation prompt if integration is not installed', async () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + kspm: { status: 'not-installed' }, + cspm: { status: 'not-installed' }, + indicesDetails: [ + { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, + { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, + ], + }, + }) + ); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + renderFindingsPage(); + + expectIdsInDoc({ + be: [PACKAGE_NOT_INSTALLED_TEST_SUBJECT], + notToBe: [ + TEST_SUBJECTS.LATEST_FINDINGS_CONTAINER, + NO_FINDINGS_STATUS_TEST_SUBJ.INDEX_TIMEOUT, + NO_FINDINGS_STATUS_TEST_SUBJ.NO_AGENTS_DEPLOYED, + NO_FINDINGS_STATUS_TEST_SUBJ.INDEXING, + NO_FINDINGS_STATUS_TEST_SUBJ.UNPRIVILEGED, + ], + }); + }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx index 940a6e0ad0f83..53fa5dae6908d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/configurations.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Redirect, Switch, useLocation } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; +import { LATEST_FINDINGS_INDEX_PATTERN } from '../../../common/constants'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; import { NoFindingsStates } from '../../components/no_findings_states'; import { CloudPosturePage } from '../../components/cloud_posture_page'; @@ -15,17 +16,20 @@ import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_ import { cloudPosturePages, findingsNavigation } from '../../common/navigation/constants'; import { FindingsByResourceContainer } from './latest_findings_by_resource/findings_by_resource_container'; import { LatestFindingsContainer } from './latest_findings/latest_findings_container'; -import { getCpmStatus } from '../../common/utils/get_cpm_status'; export const Configurations = () => { const location = useLocation(); - const dataViewQuery = useLatestFindingsDataView(); + const dataViewQuery = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); const { data: getSetupStatus } = useCspSetupStatusApi(); - const { hasFindings, isCspmInstalled } = getCpmStatus(getSetupStatus); + const hasConfigurationFindings = + getSetupStatus?.kspm.status === 'indexed' || getSetupStatus?.cspm.status === 'indexed'; - const noFindingsForPostureType = isCspmInstalled ? 'cspm' : 'kspm'; + // For now, when there are no findings we prompt first to install cspm, if it is already installed we will prompt to + // install kspm + const noFindingsForPostureType = + getSetupStatus?.cspm.status !== 'not-installed' ? 'cspm' : 'kspm'; - if (!hasFindings) return ; + if (!hasConfigurationFindings) return ; return ( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/overview_tab.tsx index a0c5d330d22a3..efc2b487d3f98 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/overview_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/findings_flyout/overview_tab.tsx @@ -23,6 +23,7 @@ import { CSP_MOMENT_FORMAT } from '../../../common/constants'; import { INTERNAL_FEATURE_FLAGS, LATEST_FINDINGS_INDEX_DEFAULT_NS, + LATEST_FINDINGS_INDEX_PATTERN, } from '../../../../common/constants'; import { useLatestFindingsDataView } from '../../../common/api/use_latest_findings_data_view'; import { useKibana } from '../../../common/hooks/use_kibana'; @@ -151,7 +152,7 @@ export const OverviewTab = ({ data }: { data: CspFinding }) => { const { services: { discover }, } = useKibana(); - const latestFindingsDataView = useLatestFindingsDataView(); + const latestFindingsDataView = useLatestFindingsDataView(LATEST_FINDINGS_INDEX_PATTERN); const discoverIndexLink = useMemo( () => diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx index 0609d86497928..174939a5e0f99 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities.tsx @@ -29,6 +29,8 @@ import { ErrorCallout } from '../configurations/layout/error_callout'; import { FindingsSearchBar } from '../configurations/layout/findings_search_bar'; import { useFilteredDataView } from '../../common/api/use_filtered_data_view'; import { CVSScoreBadge, SeverityStatusBadge } from '../../components/vulnerability_badges'; +import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states'; +import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; const getDefaultQuery = ({ query, filters }: any): any => ({ query, @@ -39,6 +41,9 @@ const getDefaultQuery = ({ query, filters }: any): any => ({ export const Vulnerabilities = () => { const { data, isLoading, error } = useFilteredDataView(LATEST_VULNERABILITIES_INDEX_PATTERN); + const getSetupStatus = useCspSetupStatusApi(); + + if (getSetupStatus?.data?.vuln_mgmt.status !== 'indexed') return ; if (error) { return ; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx new file mode 100644 index 0000000000000..f083eaf8796aa --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilties.test.tsx @@ -0,0 +1,202 @@ +/* + * 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 Chance from 'chance'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; +import { Vulnerabilities } from './vulnerabilities'; +import { + LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, + VULN_MGMT_POLICY_TEMPLATE, +} from '../../../common/constants'; +import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; +import { discoverPluginMock } from '@kbn/discover-plugin/public/mocks'; +import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { useSubscriptionStatus } from '../../common/hooks/use_subscription_status'; +import { createReactQueryResponse } from '../../test/fixtures/react_query'; +import { useCISIntegrationPoliciesLink } from '../../common/navigation/use_navigate_to_cis_integration_policies'; +import { useCspIntegrationLink } from '../../common/navigation/use_csp_integration_link'; +import { + NO_VULNERABILITIES_STATUS_TEST_SUBJ, + VULNERABILITIES_CONTAINER_TEST_SUBJ, +} from '../../components/test_subjects'; +import { render } from '@testing-library/react'; +import { expectIdsInDoc } from '../../test/utils'; +import { fleetMock } from '@kbn/fleet-plugin/public/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import { VULN_MGMT_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT } from '../../components/cloud_posture_page'; +import { TestProvider } from '../../test/test_provider'; + +jest.mock('../../common/api/use_latest_findings_data_view'); +jest.mock('../../common/api/use_setup_status_api'); +jest.mock('../../common/hooks/use_subscription_status'); +jest.mock('../../common/navigation/use_navigate_to_cis_integration_policies'); +jest.mock('../../common/navigation/use_csp_integration_link'); + +const chance = new Chance(); + +beforeEach(() => { + jest.restoreAllMocks(); + + (useSubscriptionStatus as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: true, + }) + ); +}); + +const renderVulnerabilitiesPage = () => { + render( + + + + ); +}; + +describe('', () => { + it('No vulnerabilities state: not-deployed - shows NotDeployed instead of vulnerabilities ', () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + [VULN_MGMT_POLICY_TEMPLATE]: { status: 'not-deployed' }, + indicesDetails: [{ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, status: 'empty' }], + }, + }) + ); + (useCISIntegrationPoliciesLink as jest.Mock).mockImplementation(() => chance.url()); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + + renderVulnerabilitiesPage(); + + expectIdsInDoc({ + be: [NO_VULNERABILITIES_STATUS_TEST_SUBJ.SCANNING_VULNERABILITIES], + notToBe: [ + VULNERABILITIES_CONTAINER_TEST_SUBJ, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.INDEX_TIMEOUT, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.UNPRIVILEGED, + ], + }); + }); + + it('No vulnerabilities state: indexing - shows Indexing instead of vulnerabilities ', () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + [VULN_MGMT_POLICY_TEMPLATE]: { status: 'indexing' }, + indicesDetails: [{ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, status: 'empty' }], + }, + }) + ); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + + renderVulnerabilitiesPage(); + + expectIdsInDoc({ + be: [NO_VULNERABILITIES_STATUS_TEST_SUBJ.SCANNING_VULNERABILITIES], + notToBe: [ + VULNERABILITIES_CONTAINER_TEST_SUBJ, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.INDEX_TIMEOUT, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.UNPRIVILEGED, + ], + }); + }); + + it('No vulnerabilities state: index-timeout - shows IndexTimeout instead of vulnerabilities ', () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + [VULN_MGMT_POLICY_TEMPLATE]: { status: 'index-timeout' }, + indicesDetails: [{ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, status: 'empty' }], + }, + }) + ); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + + renderVulnerabilitiesPage(); + + expectIdsInDoc({ + be: [NO_VULNERABILITIES_STATUS_TEST_SUBJ.INDEX_TIMEOUT], + notToBe: [ + VULNERABILITIES_CONTAINER_TEST_SUBJ, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.SCANNING_VULNERABILITIES, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.UNPRIVILEGED, + ], + }); + }); + + it('No vulnerabilities state: unprivileged - shows Unprivileged instead of vulnerabilities ', () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + [VULN_MGMT_POLICY_TEMPLATE]: { status: 'unprivileged' }, + indicesDetails: [{ index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, status: 'empty' }], + }, + }) + ); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + + renderVulnerabilitiesPage(); + + expectIdsInDoc({ + be: [NO_VULNERABILITIES_STATUS_TEST_SUBJ.UNPRIVILEGED], + notToBe: [ + VULNERABILITIES_CONTAINER_TEST_SUBJ, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.SCANNING_VULNERABILITIES, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.INDEX_TIMEOUT, + ], + }); + }); + + xit("renders the success state component when 'latest vulnerabilities findings' DataView exists and request status is 'success'", async () => { + // TODO: Add test cases for VulnerabilityContent + }); + + it('renders vuln_mgmt integrations installation prompt if vuln_mgmt integration is not installed', () => { + (useCspSetupStatusApi as jest.Mock).mockImplementation(() => + createReactQueryResponse({ + status: 'success', + data: { + kspm: { status: 'not-deployed' }, + cspm: { status: 'not-deployed' }, + [VULN_MGMT_POLICY_TEMPLATE]: { status: 'not-installed' }, + indicesDetails: [ + { index: 'logs-cloud_security_posture.findings_latest-default', status: 'empty' }, + { index: 'logs-cloud_security_posture.findings-default*', status: 'empty' }, + { index: LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, status: 'empty' }, + ], + }, + }) + ); + (useCspIntegrationLink as jest.Mock).mockImplementation(() => chance.url()); + + renderVulnerabilitiesPage(); + + expectIdsInDoc({ + be: [VULN_MGMT_INTEGRATION_NOT_INSTALLED_TEST_SUBJECT], + notToBe: [ + VULNERABILITIES_CONTAINER_TEST_SUBJ, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.SCANNING_VULNERABILITIES, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.INDEX_TIMEOUT, + NO_VULNERABILITIES_STATUS_TEST_SUBJ.UNPRIVILEGED, + ], + }); + }); +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts index 715044cc37c60..00835a9520c60 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/status/status.test.ts @@ -5,16 +5,16 @@ * 2.0. */ -import { calculateCspStatusCode } from './status'; +import { calculateIntegrationStatus } from './status'; import { CSPM_POLICY_TEMPLATE, VULN_MGMT_POLICY_TEMPLATE } from '../../../common/constants'; -describe('calculateCspStatusCode for cspm', () => { +describe('calculateIntegrationStatus for cspm', () => { it('Verify status when there are no permission for cspm', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'unprivileged', - findings: 'unprivileged', + latest: 'unprivileged', + stream: 'unprivileged', score: 'unprivileged', }, 1, @@ -26,11 +26,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are no findings, no healthy agents and no installed policy templates', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 0, @@ -42,11 +42,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are findings and installed policies but no healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'not-empty', + latest: 'empty', + stream: 'empty', score: 'not-empty', }, 0, @@ -58,11 +58,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are findings ,installed policies and healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'not-empty', - findings: 'not-empty', + latest: 'not-empty', + stream: 'not-empty', score: 'not-empty', }, 1, @@ -74,11 +74,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are no findings ,installed policies and no healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 0, @@ -90,11 +90,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are installed policies, healthy agents and no findings', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 1, @@ -106,11 +106,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are installed policies, healthy agents and no findings and been more than 10 minutes', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 1, @@ -122,11 +122,11 @@ describe('calculateCspStatusCode for cspm', () => { }); it('Verify status when there are installed policies, healthy agents past findings but no recent findings', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'not-empty', + latest: 'empty', + stream: 'not-empty', score: 'not-empty', }, 1, @@ -138,13 +138,13 @@ describe('calculateCspStatusCode for cspm', () => { }); }); -describe('calculateCspStatusCode for vul_mgmt', () => { +describe('calculateIntegrationStatus for vul_mgmt', () => { it('Verify status when there are no permission for vul_mgmt', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'unprivileged', - findings: 'unprivileged', + latest: 'unprivileged', + stream: 'unprivileged', score: 'unprivileged', }, 1, @@ -156,11 +156,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are no vul_mgmt findings, no healthy agents and no installed policy templates', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 0, @@ -172,11 +172,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are vul_mgmt findings and installed policies but no healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'not-empty', + latest: 'empty', + stream: 'empty', score: 'not-empty', }, 0, @@ -188,11 +188,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are vul_mgmt findings ,installed policies and healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'not-empty', - findings: 'not-empty', + latest: 'not-empty', + stream: 'not-empty', score: 'not-empty', }, 1, @@ -204,11 +204,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are no vul_mgmt findings ,installed policies and no healthy agents', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 0, @@ -220,11 +220,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are installed policies, healthy agents and no vul_mgmt findings', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 1, @@ -236,11 +236,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are installed policies, healthy agents and no vul_mgmt findings and been more than 10 minutes', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'empty', + latest: 'empty', + stream: 'empty', score: 'empty', }, 1, @@ -252,11 +252,11 @@ describe('calculateCspStatusCode for vul_mgmt', () => { }); it('Verify status when there are installed policies, healthy agents past vul_mgmt findings but no recent findings', async () => { - const statusCode = calculateCspStatusCode( + const statusCode = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: 'empty', - findings: 'not-empty', + latest: 'empty', + stream: 'not-empty', score: 'not-empty', }, 1, diff --git a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts index 8c1b708528b34..aa98b0e5e5bfd 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/status/status.ts @@ -87,11 +87,11 @@ const getHealthyAgents = async ( ); }; -export const calculateCspStatusCode = ( - postureType: PostureTypes, +export const calculateIntegrationStatus = ( + integration: PostureTypes, indicesStatus: { - findingsLatest: IndexStatus; - findings: IndexStatus; + latest: IndexStatus; + stream: IndexStatus; score?: IndexStatus; }, healthyAgents: number, @@ -99,27 +99,22 @@ export const calculateCspStatusCode = ( installedPolicyTemplates: string[] ): CspStatusCode => { // We check privileges only for the relevant indices for our pages to appear - const postureTypeCheck: PostureTypes = POSTURE_TYPES[postureType]; - if (indicesStatus.findingsLatest === 'unprivileged' || indicesStatus.score === 'unprivileged') + const postureTypeCheck: PostureTypes = POSTURE_TYPES[integration]; + if (indicesStatus.latest === 'unprivileged' || indicesStatus.score === 'unprivileged') return 'unprivileged'; + if (indicesStatus.latest === 'not-empty') return 'indexed'; + if (indicesStatus.stream === 'not-empty' && indicesStatus.latest === 'empty') return 'indexing'; + if (!installedPolicyTemplates.includes(postureTypeCheck)) return 'not-installed'; if (healthyAgents === 0) return 'not-deployed'; if ( - indicesStatus.findingsLatest === 'empty' && - indicesStatus.findings === 'empty' && + indicesStatus.latest === 'empty' && + indicesStatus.stream === 'empty' && timeSinceInstallationInMinutes < INDEX_TIMEOUT_IN_MINUTES ) return 'waiting_for_results'; - if ( - indicesStatus.findingsLatest === 'empty' && - indicesStatus.findings === 'empty' && - timeSinceInstallationInMinutes > INDEX_TIMEOUT_IN_MINUTES - ) - return 'index-timeout'; - if (indicesStatus.findingsLatest === 'empty') return 'indexing'; - if (indicesStatus.findings === 'not-empty') return 'indexed'; - throw new Error('Could not determine csp status'); + return 'index-timeout'; }; const assertResponse = (resp: CspSetupStatus, logger: CspApiRequestHandlerContext['logger']) => { @@ -261,11 +256,11 @@ export const getCspStatus = async ({ }, ]; - const statusCspm = calculateCspStatusCode( + const statusCspm = calculateIntegrationStatus( CSPM_POLICY_TEMPLATE, { - findingsLatest: findingsLatestIndexStatusCspm, - findings: findingsIndexStatusCspm, + latest: findingsLatestIndexStatusCspm, + stream: findingsIndexStatusCspm, score: scoreIndexStatusCspm, }, healthyAgentsCspm, @@ -273,11 +268,11 @@ export const getCspStatus = async ({ installedPolicyTemplates ); - const statusKspm = calculateCspStatusCode( + const statusKspm = calculateIntegrationStatus( KSPM_POLICY_TEMPLATE, { - findingsLatest: findingsLatestIndexStatusKspm, - findings: findingsIndexStatusKspm, + latest: findingsLatestIndexStatusKspm, + stream: findingsIndexStatusKspm, score: scoreIndexStatusKspm, }, healthyAgentsKspm, @@ -285,18 +280,18 @@ export const getCspStatus = async ({ installedPolicyTemplates ); - const statusVulnMgmt = calculateCspStatusCode( + const statusVulnMgmt = calculateIntegrationStatus( VULN_MGMT_POLICY_TEMPLATE, { - findingsLatest: vulnerabilitiesLatestIndexStatus, - findings: vulnerabilitiesIndexStatus, + latest: vulnerabilitiesLatestIndexStatus, + stream: vulnerabilitiesIndexStatus, }, healthyAgentsVulMgmt, calculateDiffFromNowInMinutes(installation?.install_started_at || MIN_DATE), installedPolicyTemplates ); - const statusResponseInfo = getStatusResponse({ + const statusResponseInfo: CspSetupStatus = getStatusResponse({ statusCspm, statusKspm, statusVulnMgmt, @@ -311,9 +306,7 @@ export const getCspStatus = async ({ isPluginInitialized: isPluginInitialized(), }); - if ((statusCspm && statusKspm && statusVulnMgmt) === 'not-installed') return statusResponseInfo; - - const response = { + const response: CspSetupStatus = { ...statusResponseInfo, installedPackageVersion: installation?.install_version, }; diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts index fd06d58e37081..6bb3a78e44cb4 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/findings_page.ts @@ -9,7 +9,8 @@ import expect from '@kbn/expect'; import type { FtrProviderContext } from '../ftr_provider_context'; // Defined in CSP plugin -const FINDINGS_INDEX = 'logs-cloud_security_posture.findings_latest-default'; +const FINDINGS_INDEX = 'logs-cloud_security_posture.findings-default'; +const FINDINGS_LATEST_INDEX = 'logs-cloud_security_posture.findings_latest-default'; export function FindingsPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); @@ -33,17 +34,48 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider }); const index = { - remove: () => es.indices.delete({ index: FINDINGS_INDEX, ignore_unavailable: true }), + remove: () => + Promise.all([ + es.deleteByQuery({ + index: FINDINGS_INDEX, + query: { + match_all: {}, + }, + ignore_unavailable: true, + refresh: true, + }), + es.deleteByQuery({ + index: FINDINGS_LATEST_INDEX, + query: { + match_all: {}, + }, + ignore_unavailable: true, + refresh: true, + }), + ]), add: async (findingsMock: T[]) => { - await waitForPluginInitialized(); - await Promise.all( - findingsMock.map((finding) => + await Promise.all([ + ...findingsMock.map((finding) => es.index({ index: FINDINGS_INDEX, - body: finding, + body: { + ...finding, + '@timestamp': new Date().toISOString(), + }, + refresh: true, }) - ) - ); + ), + ...findingsMock.map((finding) => + es.index({ + index: FINDINGS_LATEST_INDEX, + body: { + ...finding, + '@timestamp': new Date().toISOString(), + }, + refresh: true, + }) + ), + ]); }, }; @@ -183,6 +215,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider resourceFindingsTable, findingsByResourceTable, index, + waitForPluginInitialized, distributionBar, }; } diff --git a/x-pack/test/cloud_security_posture_functional/pages/findings.ts b/x-pack/test/cloud_security_posture_functional/pages/findings.ts index 99a399db97398..8877bc8146804 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/findings.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/findings.ts @@ -109,8 +109,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { resourceFindingsTable = findings.resourceFindingsTable; distributionBar = findings.distributionBar; + // Before we start any test we must wait for cloud_security_posture plugin to complete its initialization + await findings.waitForPluginInitialized(); + + // Prepare mocked findings await findings.index.remove(); await findings.index.add(data); + await findings.navigateToLatestFindingsPage(); await retry.waitFor( 'Findings table to be loaded', From e2d790d8a155d03818b0d80894e3d06eeba9d43c Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Wed, 12 Apr 2023 15:30:06 -0400 Subject: [PATCH 10/12] [Fleet] Update URL + add fetch retries for agent versions build task (#154847) ## Summary - Update URL to point at `/api/product_versions` per @brianjolly's input - Add basic retry loop per @jbudz suggestion cc @juliaElastic --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/dev/build/tasks/fetch_agent_versions_list.test.ts | 7 +++++++ src/dev/build/tasks/fetch_agent_versions_list.ts | 9 +++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/dev/build/tasks/fetch_agent_versions_list.test.ts b/src/dev/build/tasks/fetch_agent_versions_list.test.ts index 24ca7de045aa0..126c857df19ce 100644 --- a/src/dev/build/tasks/fetch_agent_versions_list.test.ts +++ b/src/dev/build/tasks/fetch_agent_versions_list.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ import fetch from 'node-fetch'; +import pRetry from 'p-retry'; import { REPO_ROOT } from '@kbn/repo-info'; import { ToolingLog } from '@kbn/tooling-log'; @@ -14,6 +15,7 @@ import { FetchAgentVersionsList } from './fetch_agent_versions_list'; import { Build, Config, write } from '../lib'; jest.mock('node-fetch'); +jest.mock('p-retry'); jest.mock('../lib'); const config = new Config( @@ -46,9 +48,14 @@ const config = new Config( ); const mockedFetch = fetch as jest.MockedFunction; +const mockedPRetry = pRetry as jest.MockedFunction; const mockedWrite = write as jest.MockedFunction; const mockedBuild = new Build(config); +mockedPRetry.mockImplementation((fn: any) => { + return fn(); +}); + const processEnv = process.env; describe('FetchAgentVersionsList', () => { diff --git a/src/dev/build/tasks/fetch_agent_versions_list.ts b/src/dev/build/tasks/fetch_agent_versions_list.ts index 92531081155aa..fc5e90001bca4 100644 --- a/src/dev/build/tasks/fetch_agent_versions_list.ts +++ b/src/dev/build/tasks/fetch_agent_versions_list.ts @@ -7,10 +7,14 @@ */ import fetch from 'node-fetch'; +import pRetry from 'p-retry'; import { ToolingLog } from '@kbn/tooling-log'; import { write, Task } from '../lib'; +// Endpoint maintained by the web-team and hosted on the elastic website +const PRODUCT_VERSIONS_URL = 'https://www.elastic.co/api/product_versions'; + const isPr = () => !!process.env.BUILDKITE_PULL_REQUEST && process.env.BUILDKITE_PULL_REQUEST !== 'false'; @@ -20,13 +24,10 @@ const getAvailableVersions = async (log: ToolingLog) => { 'Content-Type': 'application/json', }, }; - // Endpoint maintained by the web-team and hosted on the elastic website - // See https://github.com/elastic/website-development/issues/9331 - const url = 'https://www.elastic.co/content/product_versions'; log.info('Fetching Elastic Agent versions list'); try { - const results = await fetch(url, options); + const results = await pRetry(() => fetch(PRODUCT_VERSIONS_URL, options), { retries: 3 }); const rawBody = await results.text(); if (results.status >= 400) { From 263af61f5d7724bfa9c70dd198370411b78d839f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 12 Apr 2023 15:50:43 -0400 Subject: [PATCH 11/12] =?UTF-8?q?[Fleet]=20Fix=20package=20license=20check?= =?UTF-8?q?=20to=20use=20new=20constraints.elastic.subs=E2=80=A6=20(#15483?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes https://github.com/elastic/kibana/issues/154822 Uses the new `conditions.elastic.subscription` field to check elastic subscription. Error message when `conditions.elastic.subscription` is set to **enterprise** but ES is under **basic** license. Screenshot 2023-04-12 at 1 31 21 PM --- .../server/services/epm/packages/install.test.ts | 8 +++++--- .../fleet/server/services/epm/packages/install.ts | 14 +++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 99c711518ff2e..3f295cdf1bf68 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -136,9 +136,11 @@ describe('install', () => { jest .spyOn(Registry, 'fetchFindLatestPackageOrThrow') .mockImplementation(() => Promise.resolve({ version: '1.3.0' } as any)); - jest - .spyOn(Registry, 'getPackage') - .mockImplementation(() => Promise.resolve({ packageInfo: { license: 'basic' } } as any)); + jest.spyOn(Registry, 'getPackage').mockImplementation(() => + Promise.resolve({ + packageInfo: { license: 'basic', conditions: { elastic: { subscription: 'basic' } } }, + } as any) + ); mockGetBundledPackages.mockReset(); (install._installPackage as jest.Mock).mockClear(); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index a9b9afcf71ed6..af56737f7f8b5 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -23,6 +23,8 @@ import pRetry from 'p-retry'; import { uniqBy } from 'lodash'; +import type { LicenseType } from '@kbn/licensing-plugin/server'; + import { isPackagePrerelease, getNormalizedDataStreams } from '../../../../common/services'; import { FLEET_INSTALL_FORMAT_VERSION } from '../../../constants/fleet_es_assets'; @@ -378,6 +380,12 @@ async function installPackageFromRegistry({ } } +function getElasticSubscription(packageInfo: ArchivePackage) { + const subscription = packageInfo.conditions?.elastic?.subscription as LicenseType | undefined; + // Keep packageInfo.license for backward compatibility + return subscription || packageInfo.license || 'basic'; +} + async function installPackageCommon(options: { pkgName: string; pkgVersion: string; @@ -450,9 +458,9 @@ async function installPackageCommon(options: { }; } } - - if (!licenseService.hasAtLeast(packageInfo.license || 'basic')) { - const err = new Error(`Requires ${packageInfo.license} license`); + const elasticSubscription = getElasticSubscription(packageInfo); + if (!licenseService.hasAtLeast(elasticSubscription)) { + const err = new Error(`Requires ${elasticSubscription} license`); sendEvent({ ...telemetryEvent, errorMessage: err.message, From 515d27086d7b4c1e587a9787a8e6729558356741 Mon Sep 17 00:00:00 2001 From: Joseph McElroy Date: Wed, 12 Apr 2023 21:41:26 +0100 Subject: [PATCH 12/12] [Behavorial Analytics] Link to Manage Datastream Page (#154857) Adds a link to navigate to the manage datastream. This allows the customer to customise the ILM policy or view datastream settings. image image --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../analytics_collection_toolbar.test.tsx | 17 +++++++++- .../analytics_collection_toolbar.tsx | 31 ++++++++++++++----- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.test.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.test.tsx index 8430b948c25d1..438d533d126f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.test.tsx @@ -15,6 +15,8 @@ import { act } from 'react-dom/test-utils'; import { EuiContextMenuItem, EuiSuperDatePicker } from '@elastic/eui'; +import { AnalyticsCollection } from '../../../../../../common/types/analytics'; + import { AnalyticsCollectionToolbar } from './analytics_collection_toolbar'; describe('AnalyticsCollectionToolbar', () => { @@ -31,7 +33,10 @@ describe('AnalyticsCollectionToolbar', () => { jest.clearAllMocks(); setMockValues({ - analyticsCollection: {}, + analyticsCollection: { + events_datastream: 'test-events', + name: 'test', + } as AnalyticsCollection, dataViewId: 'data-view-test', isLoading: false, refreshInterval: { pause: false, value: 10000 }, @@ -90,4 +95,14 @@ describe('AnalyticsCollectionToolbar', () => { expect(exploreInDiscoverItem.prop('href')).toBe("/app/discover#/?_a=(index:'data-view-test')"); }); + + it('should correct link to the manage datastream link', () => { + const exploreInDiscoverItem = wrapper.find(EuiContextMenuItem).at(1); + + expect(exploreInDiscoverItem).toHaveLength(1); + + expect(exploreInDiscoverItem.prop('href')).toBe( + '/app/management/data/index_management/data_streams/test-events' + ); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.tsx b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.tsx index f2b2a0badc6d4..4162b13569089 100644 --- a/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/analytics/components/analytics_collection_view/analytics_collection_toolbar/analytics_collection_toolbar.tsx @@ -88,6 +88,9 @@ export const AnalyticsCollectionToolbar: React.FC = () => { const discoverUrl = application.getUrlForApp('discover', { path: `#/?_a=(index:'${dataViewId}')`, }); + const manageDatastreamUrl = application.getUrlForApp('management', { + path: '/data/index_management/data_streams/' + analyticsCollection.events_datastream, + }); const handleTimeChange = ({ start: from, end: to }: OnTimeChangeProps) => { setTimeRange({ from, to }); }; @@ -139,16 +142,10 @@ export const AnalyticsCollectionToolbar: React.FC = () => { panelPaddingSize="none" > - - - - navigateToUrl( generateEncodedPath(COLLECTION_INTEGRATE_PATH, { @@ -164,7 +161,24 @@ export const AnalyticsCollectionToolbar: React.FC = () => { - + + + + + { fullWidth isLoading={!isLoading} disabled={!isLoading} + data-telemetry-id={'entSearch-analytics-overview-toolbar-delete-collection-button'} size="s" onClick={() => { deleteAnalyticsCollection(analyticsCollection.name);