diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/chart_split.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/chart_split.tsx new file mode 100644 index 0000000000000..4f30ab63d4bb0 --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/chart_split.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { Accessor, AccessorFn, GroupBy, SmallMultiples, Predicate } from '@elastic/charts'; + +interface ChartSplitProps { + splitColumnAccessor?: Accessor | AccessorFn; + splitRowAccessor?: Accessor | AccessorFn; +} + +const CHART_SPLIT_ID = '__heatmap_chart_split__'; +const SMALL_MULTIPLES_ID = '__heatmap_chart_sm__'; + +export const ChartSplit = ({ splitColumnAccessor, splitRowAccessor }: ChartSplitProps) => { + if (!splitColumnAccessor && !splitRowAccessor) return null; + + return ( + <> + { + const splitTypeAccessor = splitColumnAccessor || splitRowAccessor; + if (splitTypeAccessor) { + return typeof splitTypeAccessor === 'function' + ? splitTypeAccessor(datum) + : datum[splitTypeAccessor]; + } + return spec.id; + }} + sort={Predicate.DataIndex} + /> + + + ); +}; diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index 8bae0d35e85c2..f243fa4d67263 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -50,6 +50,8 @@ import { LegendColorPickerWrapper, } from '../utils/get_color_picker'; import { defaultPaletteParams } from '../constants'; +import { ChartSplit } from './chart_split'; +import { getSplitDimensionAccessor, createSplitPoint } from '../utils/get_split_dimension_utils'; import './index.scss'; declare global { @@ -207,7 +209,6 @@ export const HeatmapComponent: FC = memo( () => findMinMaxByColumnId([valueAccessor!], table), [valueAccessor, table] ); - const paletteParams = args.palette?.params; const xAccessor = args.xAccessor ? getAccessorByDimension(args.xAccessor, table.columns) @@ -215,6 +216,12 @@ export const HeatmapComponent: FC = memo( const yAccessor = args.yAccessor ? getAccessorByDimension(args.yAccessor, table.columns) : undefined; + const splitChartRowAccessor = args.splitRowAccessor + ? getSplitDimensionAccessor(data.columns, args.splitRowAccessor, formatFactory) + : undefined; + const splitChartColumnAccessor = args.splitColumnAccessor + ? getSplitDimensionAccessor(data.columns, args.splitColumnAccessor, formatFactory) + : undefined; const xAxisColumnIndex = table.columns.findIndex((v) => v.id === xAccessor); const yAxisColumnIndex = table.columns.findIndex((v) => v.id === yAccessor); @@ -252,7 +259,7 @@ export const HeatmapComponent: FC = memo( const onElementClick = useCallback( (e: HeatmapElementEvent[]) => { const cell = e[0][0]; - const { x, y } = cell.datum; + const { x, y, smVerticalAccessorValue, smHorizontalAccessorValue } = cell.datum; const points = [ { @@ -284,6 +291,28 @@ export const HeatmapComponent: FC = memo( : []), ]; + if (smHorizontalAccessorValue && args.splitColumnAccessor) { + const point = createSplitPoint( + args.splitColumnAccessor, + smHorizontalAccessorValue, + formatFactory, + table + ); + if (point) { + points.push(point); + } + } + if (smVerticalAccessorValue && args.splitRowAccessor) { + const point = createSplitPoint( + args.splitRowAccessor, + smVerticalAccessorValue, + formatFactory, + table + ); + if (point) { + points.push(point); + } + } const context: FilterEvent['data'] = { data: points.map((point) => ({ row: point.row, @@ -295,6 +324,9 @@ export const HeatmapComponent: FC = memo( onClickValue(context); }, [ + args.splitColumnAccessor, + args.splitRowAccessor, + formatFactory, formattedTable.formattedColumns, onClickValue, table, @@ -579,6 +611,10 @@ export const HeatmapComponent: FC = memo( }} > + { + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + + beforeEach(() => { + defaultFormatter.mockClear(); + }); + + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: { + id: data.columns[2].id, + name: data.columns[2].name, + meta: data.columns[2].meta, + }, + format: { + params: {}, + }, + }; + + it('returns accessor which is using formatter, if meta.params are present at accessing column', () => { + const accessor = getSplitDimensionAccessor(data.columns, splitDimension, defaultFormatter); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(typeof accessor).toBe('function'); + accessor(data.rows[0]); + }); + + it('returns accessor which is using default formatter, if meta.params and format are not present', () => { + const column: Partial = { + ...data.columns[2], + meta: { type: 'number' }, + }; + const columns = [data.columns[0], column, data.columns[2]] as DatatableColumn[]; + const defaultFormatterReturnedVal = fieldFormatsMock.deserialize(); + const spyOnDefaultFormatterConvert = jest.spyOn(defaultFormatterReturnedVal, 'convert'); + + defaultFormatter.mockReturnValueOnce(defaultFormatterReturnedVal); + const accessor = getSplitDimensionAccessor(columns, splitDimension, defaultFormatter); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + + expect(typeof accessor).toBe('function'); + accessor(data.rows[0]); + expect(spyOnDefaultFormatterConvert).toHaveBeenCalledTimes(1); + }); + + it('returns accessor which returns undefined, if such column is not present', () => { + const accessor1 = getSplitDimensionAccessor(data.columns, splitDimension, defaultFormatter); + + expect(typeof accessor1).toBe('function'); + const result1 = accessor1({}); + expect(result1).toBeUndefined(); + + const column2: Partial = { + ...data.columns[2], + meta: { type: 'string' }, + }; + const columns2 = [data.columns[0], data.columns[1], column2] as DatatableColumn[]; + const accessor2 = getSplitDimensionAccessor(columns2, splitDimension, defaultFormatter); + + expect(typeof accessor2).toBe('function'); + const result2 = accessor1({}); + expect(result2).toBeUndefined(); + + const column3 = { + ...data.columns[2], + meta: { type: 'string' }, + format: { + id: 'string', + params: {}, + }, + }; + const columns3 = [data.columns[0], data.columns[1], column3] as DatatableColumn[]; + + const accessor3 = getSplitDimensionAccessor(columns3, splitDimension, defaultFormatter); + expect(typeof accessor3).toBe('function'); + const result3 = accessor3({}); + expect(result3).toBeUndefined(); + }); +}); + +describe('createSplitPoint', () => { + const defaultFormatter = jest.fn((...args) => fieldFormatsMock.deserialize(...args)); + + beforeEach(() => { + defaultFormatter.mockClear(); + }); + + const splitDimension: ExpressionValueVisDimension = { + type: 'vis_dimension', + accessor: { + id: data.columns[2].id, + name: data.columns[2].name, + meta: data.columns[2].meta, + }, + format: { + params: {}, + }, + }; + + it('returns point if value is found in the table', () => { + const point = createSplitPoint(splitDimension, 'c', defaultFormatter, data); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(point).toStrictEqual({ column: 2, row: 1, value: 'c' }); + }); + + it('returns undefined if value is not found in the table', () => { + const point = createSplitPoint(splitDimension, 'test', defaultFormatter, data); + + expect(defaultFormatter).toHaveBeenCalledTimes(1); + expect(point).toBeUndefined(); + }); +}); diff --git a/src/plugins/chart_expressions/expression_heatmap/public/utils/get_split_dimension_utils.ts b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_split_dimension_utils.ts new file mode 100644 index 0000000000000..e59d6c1aec28b --- /dev/null +++ b/src/plugins/chart_expressions/expression_heatmap/public/utils/get_split_dimension_utils.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { AccessorFn } from '@elastic/charts'; +import type { DatatableColumn, Datatable } from '@kbn/expressions-plugin/public'; +import type { FormatFactory } from '@kbn/field-formats-plugin/common'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; + +export const getSplitDimensionAccessor = ( + columns: DatatableColumn[], + splitDimension: ExpressionValueVisDimension | string, + formatFactory: FormatFactory +): AccessorFn => { + const splitChartColumn = getColumnByAccessor(splitDimension, columns)!; + const accessor = splitChartColumn.id; + + const formatter = formatFactory(splitChartColumn.meta?.params); + const fn: AccessorFn = (d) => { + const v = d[accessor]; + if (v === undefined) { + return; + } + + const f = formatter.convert(v); + return f; + }; + + return fn; +}; + +export function createSplitPoint( + splitDimension: ExpressionValueVisDimension | string, + value: string | number, + formatFactory: FormatFactory, + table: Datatable +) { + const splitChartColumn = getColumnByAccessor(splitDimension, table.columns)!; + const accessor = splitChartColumn.id; + + const formatter = formatFactory(splitChartColumn.meta?.params); + const splitPointRowIndex = table.rows.findIndex((row) => { + return formatter.convert(row[accessor]) === value; + }); + if (splitPointRowIndex !== -1) { + return { + row: splitPointRowIndex, + column: table.columns.findIndex((column) => column.id === accessor), + value: table.rows[splitPointRowIndex][accessor], + }; + } +} diff --git a/src/plugins/discover/public/components/doc_table/components/table_header/table_header.tsx b/src/plugins/discover/public/components/doc_table/components/table_header/table_header.tsx index ef5ca408d2bd8..78a7ab5f44ff8 100644 --- a/src/plugins/discover/public/components/doc_table/components/table_header/table_header.tsx +++ b/src/plugins/discover/public/components/doc_table/components/table_header/table_header.tsx @@ -47,10 +47,10 @@ export function TableHeader({ return ( - {displayedColumns.map((col) => { + {displayedColumns.map((col, index) => { return ( ); } else { - columns.forEach(function (column: string) { + columns.forEach(function (column: string, index) { + const cellKey = `${column}-${index}`; if (useNewFieldsApi && !mapping(column) && row.raw.fields && !row.raw.fields[column]) { const innerColumns = Object.fromEntries( Object.entries(row.raw.fields).filter(([key]) => { @@ -163,7 +164,7 @@ export const TableRow = ({ rowCells.push( { ); return ( - - - - {shouldShowLimitedResultsWarning && ( - - - - - - )} - {props.totalHitCount !== 0 && ( - - - - )} - - - - - - - - {showPagination && ( - + + + + ) : undefined + } + append={ + showPagination ? ( { onPageClick={onPageChange} onPageSizeChange={onPageSizeChange} /> - - )} - + ) : undefined + } + > + + ); }; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 449c83b77966c..a797a78ba6875 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -132,13 +132,16 @@ describe('saved search embeddable', () => { jest.spyOn(embeddable, 'updateOutput'); embeddable.render(mountpoint); + expect(render).toHaveBeenCalledTimes(1); await waitOneTick(); + expect(render).toHaveBeenCalledTimes(2); const searchProps = discoverComponent.find(SavedSearchEmbeddableComponent).prop('searchProps'); searchProps.onAddColumn!('bytes'); await waitOneTick(); expect(searchProps.columns).toEqual(['message', 'extension', 'bytes']); + expect(render).toHaveBeenCalledTimes(4); // twice per an update to show and then hide a loading indicator searchProps.onRemoveColumn!('bytes'); await waitOneTick(); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index d89b98de0c93a..0e315d3b348e5 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -198,6 +198,8 @@ export class SavedSearchEmbeddable this.searchProps!.isLoading = true; + const wasAlreadyRendered = this.getOutput().rendered; + this.updateOutput({ ...this.getOutput(), loading: true, @@ -205,6 +207,11 @@ export class SavedSearchEmbeddable error: undefined, }); + if (wasAlreadyRendered && this.node) { + // to show a loading indicator during a refetch, we need to rerender here + this.render(this.node); + } + const parentContext = this.input.executionContext; const child: KibanaExecutionContext = { type: this.type, diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx new file mode 100644 index 0000000000000..17785570b9487 --- /dev/null +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx @@ -0,0 +1,67 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; +import { TotalDocuments } from '../application/main/components/total_documents/total_documents'; + +const containerStyles = css` + width: 100%; + position: relative; +`; + +export interface SavedSearchEmbeddableBaseProps { + isLoading: boolean; + totalHitCount: number; + prepend?: React.ReactElement; + append?: React.ReactElement; + dataTestSubj?: string; +} + +export const SavedSearchEmbeddableBase: React.FC = ({ + isLoading, + totalHitCount, + prepend, + append, + dataTestSubj, + children, +}) => { + return ( + + {isLoading && } + + + {Boolean(prepend) && {prepend}} + + {Boolean(totalHitCount) && ( + + + + )} + + + + {children} + + {Boolean(append) && {append}} + + ); +}; diff --git a/src/plugins/discover/public/embeddable/saved_search_grid.tsx b/src/plugins/discover/public/embeddable/saved_search_grid.tsx index a1e75e17dca5f..21514a61c87a9 100644 --- a/src/plugins/discover/public/embeddable/saved_search_grid.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_grid.tsx @@ -6,12 +6,11 @@ * Side Public License, v 1. */ import React, { useState, memo } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DataTableRecord } from '../types'; import { DiscoverGrid, DiscoverGridProps } from '../components/discover_grid/discover_grid'; -import { TotalDocuments } from '../application/main/components/total_documents/total_documents'; import './saved_search_grid.scss'; import { DiscoverGridFlyout } from '../components/discover_grid/discover_grid_flyout'; +import { SavedSearchEmbeddableBase } from './saved_search_embeddable_base'; export interface DiscoverGridEmbeddableProps extends DiscoverGridProps { totalHitCount: number; @@ -21,27 +20,19 @@ export const DataGridMemoized = memo(DiscoverGrid); export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) { const [expandedDoc, setExpandedDoc] = useState(undefined); + return ( - - {Boolean(props.totalHitCount) && props.totalHitCount !== 0 && ( - - - - )} - - - - + + ); } diff --git a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.ts similarity index 94% rename from src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx rename to src/plugins/vis_types/heatmap/public/vis_type/heatmap.ts index 6c4b63c1095a7..97f6db9668a2a 100644 --- a/src/plugins/vis_types/heatmap/public/vis_type/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/vis_type/heatmap.ts @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React from 'react'; import { i18n } from '@kbn/i18n'; import { Position } from '@elastic/charts'; @@ -15,7 +14,6 @@ import { VIS_EVENT_TO_TRIGGER, VisTypeDefinition } from '@kbn/visualizations-plu import { HeatmapTypeProps, HeatmapVisParams, AxisType, ScaleType } from '../types'; import { toExpressionAst } from '../to_ast'; import { getHeatmapOptions } from '../editor/components'; -import { SplitTooltip } from './split_tooltip'; import { convertToLens } from '../convert_to_lens'; export const getHeatmapVisTypeDefinition = ({ @@ -129,11 +127,6 @@ export const getHeatmapVisTypeDefinition = ({ { group: AggGroupNames.Buckets, name: 'split', - // TODO: Remove when split chart aggs are supported - ...(showElasticChartsOptions && { - disabled: true, - tooltip: , - }), title: i18n.translate('visTypeHeatmap.heatmap.splitTitle', { defaultMessage: 'Split chart', }), diff --git a/src/plugins/vis_types/heatmap/public/vis_type/split_tooltip.tsx b/src/plugins/vis_types/heatmap/public/vis_type/split_tooltip.tsx deleted file mode 100644 index 571ec2f33bca1..0000000000000 --- a/src/plugins/vis_types/heatmap/public/vis_type/split_tooltip.tsx +++ /dev/null @@ -1,19 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ -import React from 'react'; - -import { FormattedMessage } from '@kbn/i18n-react'; - -export function SplitTooltip() { - return ( - - ); -} diff --git a/src/plugins/vis_types/heatmap/server/plugin.ts b/src/plugins/vis_types/heatmap/server/plugin.ts index b14afa2ac67e8..70b9b9adad8d4 100644 --- a/src/plugins/vis_types/heatmap/server/plugin.ts +++ b/src/plugins/vis_types/heatmap/server/plugin.ts @@ -23,13 +23,23 @@ export const getUiSettingsConfig: () => Record } ), requiresPageReload: true, - value: true, + value: false, description: i18n.translate( 'visTypeHeatmap.advancedSettings.visualization.legacyHeatmapChartsLibrary.description', { defaultMessage: 'Enables legacy charts library for heatmap charts in visualize.', } ), + deprecation: { + message: i18n.translate( + 'visTypeHeatmap.advancedSettings.visualization.legacyHeatmapChartsLibrary.deprecation', + { + defaultMessage: + 'The legacy charts library for heatmap in visualize is deprecated and will not be supported in a future version.', + } + ), + docLinksKey: 'visualizationSettings', + }, category: ['visualization'], schema: schema.boolean(), }, diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx index b60a0ae931872..cb33226318742 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_editor_common.test.tsx @@ -120,47 +120,6 @@ describe('VisualizeEditorCommon', () => { }); }); - it('should display a warning callout for new heatmap implementation with split aggs', async () => { - const wrapper = shallowWithIntl( - {}} - hasUnappliedChanges={false} - isEmbeddableRendered={false} - onAppLeave={() => {}} - visEditorRef={React.createRef()} - visInstance={ - { - savedVis: { - id: 'test', - sharingSavedObjectProps: { - outcome: 'conflict', - aliasTargetId: 'alias_id', - }, - }, - vis: { - type: { - title: 'Heatmap', - name: 'heatmap', - }, - data: { - aggs: { - aggs: [ - { - schema: 'split', - }, - ], - }, - }, - }, - } as unknown as VisualizeEditorVisInstance - } - /> - ); - expect(wrapper.find(VizChartWarning).length).toBe(1); - }); - it('should not display a warning callout for XY charts with split aggs', async () => { const wrapper = shallowWithIntl( = (props) => { ); }; -const HeatmapWarningFormatMessage: FC = (props) => { - return ( - - - - - ), - }} - /> - ); -}; - const PieWarningFormatMessage: FC = (props) => { return ( = (props) => { }; const warningMessages = { - [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: HeatmapWarningFormatMessage, [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: GaugeWarningFormatMessage, [CHARTS_TO_BE_DEPRECATED.pie]: PieWarningFormatMessage, [CHARTS_TO_BE_DEPRECATED.controls]: ControlsWarningFormatMessage, diff --git a/src/plugins/visualizations/public/visualize_app/constants.ts b/src/plugins/visualizations/public/visualize_app/constants.ts index fd256cb5bbb86..4a489abec1f2e 100644 --- a/src/plugins/visualizations/public/visualize_app/constants.ts +++ b/src/plugins/visualizations/public/visualize_app/constants.ts @@ -6,5 +6,4 @@ * Side Public License, v 1. */ -export const NEW_HEATMAP_CHARTS_LIBRARY = 'visualization:visualize:legacyHeatmapChartsLibrary'; export const NEW_GAUGE_CHARTS_LIBRARY = 'visualization:visualize:legacyGaugeChartsLibrary'; diff --git a/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts index 5efbe7494ef59..09699d749158a 100644 --- a/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts +++ b/src/plugins/visualizations/public/visualize_app/utils/split_chart_warning_helpers.ts @@ -8,10 +8,9 @@ import { $Values } from '@kbn/utility-types'; import { AggConfigs } from '@kbn/data-plugin/common'; -import { NEW_HEATMAP_CHARTS_LIBRARY, NEW_GAUGE_CHARTS_LIBRARY } from '../constants'; +import { NEW_GAUGE_CHARTS_LIBRARY } from '../constants'; export const CHARTS_WITHOUT_SMALL_MULTIPLES = { - heatmap: 'heatmap', gauge: 'gauge', } as const; @@ -24,7 +23,6 @@ export type CHARTS_WITHOUT_SMALL_MULTIPLES = $Values; export const CHARTS_CONFIG_TOKENS = { - [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: NEW_HEATMAP_CHARTS_LIBRARY, [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: NEW_GAUGE_CHARTS_LIBRARY, } as const; @@ -32,7 +30,6 @@ export const isSplitChart = (chartType: string | undefined, aggs?: AggConfigs) = const defaultIsSplitChart = () => aggs?.aggs.some((agg) => agg.schema === 'split'); const knownCheckers = { - [CHARTS_WITHOUT_SMALL_MULTIPLES.heatmap]: defaultIsSplitChart, [CHARTS_WITHOUT_SMALL_MULTIPLES.gauge]: () => aggs?.aggs.some((agg) => agg.schema === 'group'), }; diff --git a/test/functional/apps/dashboard/group1/embeddable_rendering.ts b/test/functional/apps/dashboard/group1/embeddable_rendering.ts index d2da02ff1459d..f266b07a986f9 100644 --- a/test/functional/apps/dashboard/group1/embeddable_rendering.ts +++ b/test/functional/apps/dashboard/group1/embeddable_rendering.ts @@ -75,7 +75,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const expectNoDataRenders = async () => { await pieChart.expectEmptyPieChart(); - await dashboardExpect.seriesElementCount(0); + await dashboardExpect.heatMapNoResults(); await dashboardExpect.dataTableNoResult(); await dashboardExpect.savedSearchNoResult(); await dashboardExpect.inputControlItemCount(5); diff --git a/test/functional/apps/dashboard/group2/dashboard_filtering.ts b/test/functional/apps/dashboard/group2/dashboard_filtering.ts index 68b80c0168e2d..1fb70ef508c2b 100644 --- a/test/functional/apps/dashboard/group2/dashboard_filtering.ts +++ b/test/functional/apps/dashboard/group2/dashboard_filtering.ts @@ -91,7 +91,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('area, bar and heatmap charts filtered', async () => { - await dashboardExpect.seriesElementCount(0); + await dashboardExpect.heatMapNoResults(); }); it('data tables are filtered', async () => { @@ -156,7 +156,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('area, bar and heatmap charts filtered', async () => { - await dashboardExpect.seriesElementCount(0); + await dashboardExpect.heatMapNoResults(); }); it('data tables are filtered', async () => { @@ -212,7 +212,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('area, bar and heatmap charts', async () => { - await dashboardExpect.seriesElementCount(2); + await dashboardExpect.heatmapXAxisBuckets(11); }); it('data tables', async () => { diff --git a/test/functional/apps/visualize/group2/index.ts b/test/functional/apps/visualize/group2/index.ts index 5a83044deee17..7ece37d611ed2 100644 --- a/test/functional/apps/visualize/group2/index.ts +++ b/test/functional/apps/visualize/group2/index.ts @@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.uiSettings.update({ 'histogram:maxBars': 100, + 'visualization:visualize:legacyHeatmapChartsLibrary': true, }); await browser.refresh(); @@ -31,6 +32,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await kibanaServer.uiSettings.update({ 'histogram:maxBars': 1000, + 'visualization:visualize:legacyHeatmapChartsLibrary': false, }); await browser.refresh(); }); diff --git a/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts b/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts index d8e35e8f12bfc..a2118e55ecd52 100644 --- a/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts +++ b/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts @@ -27,7 +27,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyHeatmapChartsLibrary': false, 'histogram:maxBars': 100, }); await browser.refresh(); @@ -35,7 +34,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyHeatmapChartsLibrary': true, 'histogram:maxBars': 1000, }); await browser.refresh(); diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 266022d1a8157..677e16914f3de 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -16,6 +16,7 @@ export class DashboardExpectService extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); private readonly filterBar = this.ctx.getService('filterBar'); + private readonly elasticChart = this.ctx.getService('elasticChart'); private readonly dashboard = this.ctx.getPageObject('dashboard'); private readonly visChart = this.ctx.getPageObject('visChart'); @@ -304,12 +305,22 @@ export class DashboardExpectService extends FtrService { }); } + // heatmap data async seriesElementCount(expectedCount: number) { this.log.debug(`DashboardExpect.seriesElementCount(${expectedCount})`); - await this.retry.try(async () => { - const seriesElements = await this.find.allByCssSelector('.series', this.findTimeout); - expect(seriesElements.length).to.be(expectedCount); - }); + const heatmapData = await this.elasticChart.getChartDebugData('heatmapChart'); + this.log.debug(heatmapData.axes?.y[0]); + expect(heatmapData.axes?.y[0].labels.length).to.be(expectedCount); + } + + async heatmapXAxisBuckets(expectedCount: number) { + this.log.debug(`DashboardExpect.heatmapXAxisBuckets(${expectedCount})`); + const heatmapData = await this.elasticChart.getChartDebugData('heatmapChart'); + expect(heatmapData.axes?.x[0].labels.length).to.be(expectedCount); + } + + async heatMapNoResults() { + await this.testSubjects.find('heatmapChart>emptyPlaceholder'); } // legacy controls visualization diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index f12abcde69660..8b4c04d15cfc4 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -57,6 +57,7 @@ export interface AlertingFrameworkHealth { export const LEGACY_BASE_ALERT_API_PATH = '/api/alerts'; export const BASE_ALERTING_API_PATH = '/api/alerting'; export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting'; +export const INTERNAL_ALERTING_API_FIND_RULES_PATH = `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`; export const ALERTS_FEATURE_ID = 'alerts'; export const MONITORING_HISTORY_LIMIT = 200; export const ENABLE_MAINTENANCE_WINDOWS = false; diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index 50bd9ad387d7d..04b18da1a1b0c 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -20,7 +20,7 @@ import { RuleTypeParams, AlertingRequestHandlerContext, BASE_ALERTING_API_PATH, - INTERNAL_BASE_ALERTING_API_PATH, + INTERNAL_ALERTING_API_FIND_RULES_PATH, } from '../types'; import { trackLegacyTerminology } from './lib/track_legacy_terminology'; @@ -136,7 +136,7 @@ const buildFindRulesRoute = ({ }) ) ); - if (path === `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`) { + if (path === INTERNAL_ALERTING_API_FIND_RULES_PATH) { router.post( { path, @@ -205,7 +205,7 @@ export const findInternalRulesRoute = ( buildFindRulesRoute({ excludeFromPublicApi: false, licenseState, - path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_find`, + path: INTERNAL_ALERTING_API_FIND_RULES_PATH, router, usageCounter, }); 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 7fb386ae16160..3e84cc663d0c5 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 @@ -92,7 +92,7 @@ export const CspNoDataPage = ({ ); }; -const defaultLoadingRenderer = () => ( +export const defaultLoadingRenderer = () => ( ( ); -const defaultNoDataRenderer = () => ( +export const defaultNoDataRenderer = () => ( { @@ -53,7 +53,7 @@ export const CVSScoreBadge = ({ score, version }: CVSScoreBadgeProps) => { }; export const SeverityStatusBadge = ({ score, status }: SeverityStatusBadgeProps) => { - const color = getCvsScoreColor(score); + const color = score ? getCvsScoreColor(score) : undefined; return (
- + {status}
); 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 index 798f66fa0dfca..2869d8d7da321 100644 --- 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 @@ -20,10 +20,11 @@ interface FindingsAggs { count: estypes.AggregationsMultiBucketAggregateBase; } -export const getFindingsQuery = ({ query }: any) => ({ +export const getFindingsQuery = ({ query, sort }: any) => ({ index: LATEST_VULNERABILITIES_INDEX_PATTERN, query, size: MAX_FINDINGS_TO_LOAD, + sort, }); export const useLatestVulnerabilities = (options: any) => { 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 index 4e9c346e3969a..1e330a00b4fcf 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils.ts @@ -5,88 +5,8 @@ * 2.0. */ -import { EuiDataGridColumn } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { VectorScoreBase, Vector } from './types'; -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', - }), - }, - ]; -}; - export const getVectorScoreList = (vectorBaseScore: VectorScoreBase) => { const result: Vector[] = []; const v2Vector = vectorBaseScore?.V2Vector; 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 19f0cb88aa219..bc54dd08f5126 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 @@ -9,7 +9,7 @@ import { EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, - EuiLoadingSpinner, + EuiProgress, EuiSpacer, useEuiTheme, } from '@elastic/eui'; @@ -21,7 +21,6 @@ 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'; @@ -31,6 +30,13 @@ import { EmptyState } from '../../components/empty_state'; import { VulnerabilityFindingFlyout } from './vulnerabilities_finding_flyout/vulnerability_finding_flyout'; import { NoVulnerabilitiesStates } from '../../components/no_vulnerabilities_states'; import { useCspSetupStatusApi } from '../../common/api/use_setup_status_api'; +import { useLimitProperties } from '../../common/utils/get_limit_properties'; +import { LimitedResultsBar } from '../configurations/layout/findings_layout'; +import { + getVulnerabilitiesColumnsGrid, + vulnerabilitiesColumns, +} from './vulnerabilities_table_columns'; +import { defaultLoadingRenderer, defaultNoDataRenderer } from '../../components/cloud_posture_page'; const getDefaultQuery = ({ query, filters }: any): any => ({ query, @@ -48,8 +54,12 @@ export const Vulnerabilities = () => { if (error) { return ; } - if (isLoading || !data) { - return ; + if (isLoading) { + return defaultLoadingRenderer(); + } + + if (!data) { + return defaultNoDataRenderer(); } return ; @@ -74,9 +84,15 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { }); const { euiTheme } = useEuiTheme(); - const { data, isLoading } = useLatestVulnerabilities({ + const multiFieldsSort = useMemo(() => { + return sort.map(({ id, direction }: { id: string; direction: string }) => ({ + [id]: direction, + })); + }, [sort]); + + const { data, isLoading, isFetching } = useLatestVulnerabilities({ query, - sort, + sort: multiFieldsSort, enabled: !queryError, }); @@ -95,6 +111,16 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { setVulnerability(undefined); }; + const { isLastLimitedPage, limitedTotalItemCount } = useLimitProperties({ + total: data?.total, + pageIndex, + pageSize, + }); + + const columns = useMemo(() => { + return getVulnerabilitiesColumnsGrid(); + }, []); + const renderCellValue = useMemo(() => { return ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { const vulnerabilityRow = data?.page[rowIndex] as VulnerabilityRecord; @@ -114,7 +140,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { ); } if (columnId === vulnerabilitiesColumns.vulnerability) { - return vulnerabilityRow.vulnerability.id || null; + return vulnerabilityRow.vulnerability.id || ''; } if (columnId === vulnerabilitiesColumns.cvss) { if (!vulnerabilityRow.vulnerability.score?.base) { @@ -131,15 +157,12 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { return vulnerabilityRow.resource?.name || null; } if (columnId === vulnerabilitiesColumns.severity) { - if ( - !vulnerabilityRow.vulnerability.score?.base || - !vulnerabilityRow.vulnerability.severity - ) { + if (!vulnerabilityRow.vulnerability.severity) { return null; } return ( ); @@ -168,11 +191,13 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { if (error) { return ; } - if (isLoading || !data?.page) { - return ; + if (isLoading) { + return defaultLoadingRenderer(); } - const columns = getVulnerabilitiesColumnsGrid(); + if (!data?.page) { + return defaultNoDataRenderer(); + } return ( <> @@ -188,6 +213,15 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { ) : ( <> + {isFetching ? ( + + ) : ( + + )} { visibleColumns: columns.map(({ id }) => id), setVisibleColumns: () => {}, }} - rowCount={data?.total} + rowCount={limitedTotalItemCount} toolbarVisibility={{ showColumnSelector: false, showDisplaySelector: false, @@ -236,7 +270,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { header: 'underline', }} renderCellValue={renderCellValue} - inMemory={{ level: 'sorting' }} + inMemory={{ level: 'pagination' }} sorting={{ columns: sort, onSort }} pagination={{ pageIndex, @@ -246,6 +280,7 @@ const VulnerabilitiesContent = ({ dataView }: { dataView: DataView }) => { onChangePage, }} /> + {isLastLimitedPage && } {/* Todo: Add Pagination */} {isVulnerabilityDetailFlyoutVisible && !!vulnerability && ( @@ -101,7 +99,7 @@ export const VulnerabilityFindingFlyout = ({ > diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx index 9ecfbdd5eb917..547bec3c9b173 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_finding_flyout/vulnerability_overview_tab.tsx @@ -118,20 +118,23 @@ const VulnerabilityOverviewTiles = ({ vulnerability }: VulnerabilityTabProps) => return ( - - - - -
- -
-
+ {vulnerability?.score?.version && vulnerability?.score?.impact && ( + + + + + +
+ +
+
+ )} (vulnerability?.cvss).map( - ([vendor, vectorScoreBase]: [string, VectorScoreBase]) => { - return ( - - - - ); - } - ); + const cvssScores: JSX.Element[] = vulnerability?.cvss + ? Object.entries(vulnerability.cvss).map( + ([vendor, vectorScoreBase]: [string, VectorScoreBase]) => { + return ( + + + + ); + } + ) + : []; const horizontalStyle = css` margin-block: 12px; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_table_columns.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_table_columns.ts new file mode 100644 index 0000000000000..88a6a8e569c80 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/vulnerabilities_table_columns.ts @@ -0,0 +1,85 @@ +/* + * 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.id', + cvss: 'vulnerability.score.base', + resource: 'resource.name', + severity: 'vulnerability.severity', + package_version: 'vulnerability.package.name', + fix_version: 'vulnerability.package.fixed_version', +}; + +const defaultColumnProps = () => ({ + isExpandable: false, + actions: { + showHide: false, + showMoveLeft: false, + showMoveRight: false, + }, +}); + +export const getVulnerabilitiesColumnsGrid = (): EuiDataGridColumn[] => [ + { + ...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/server/lib/check_index_status.ts b/x-pack/plugins/cloud_security_posture/server/lib/check_index_status.ts index 47ab871dc9d21..273ead42f4443 100644 --- a/x-pack/plugins/cloud_security_posture/server/lib/check_index_status.ts +++ b/x-pack/plugins/cloud_security_posture/server/lib/check_index_status.ts @@ -12,22 +12,19 @@ export const checkIndexStatus = async ( esClient: ElasticsearchClient, index: string, logger: Logger, - postureType: PostureTypes = 'all' + postureType?: PostureTypes ): Promise => { - const query = - postureType === 'all' - ? { - match_all: {}, - } - : { - bool: { - filter: { - term: { - 'rule.benchmark.posture_type': postureType, - }, + const query = !postureType + ? undefined + : { + bool: { + filter: { + term: { + 'rule.benchmark.posture_type': postureType, }, }, - }; + }, + }; try { const queryResult = await esClient.search({ 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 aa98b0e5e5bfd..69ae5eae2a414 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 @@ -167,12 +167,7 @@ export const getCspStatus = async ({ checkIndexStatus(esClient, FINDINGS_INDEX_PATTERN, logger, 'kspm'), checkIndexStatus(esClient, BENCHMARK_SCORE_INDEX_DEFAULT_NS, logger, 'kspm'), - checkIndexStatus( - esClient, - LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, - logger, - VULN_MGMT_POLICY_TEMPLATE - ), + checkIndexStatus(esClient, LATEST_VULNERABILITIES_INDEX_DEFAULT_NS, logger), checkIndexStatus(esClient, VULNERABILITIES_INDEX_PATTERN, logger, VULN_MGMT_POLICY_TEMPLATE), packageService.asInternalUser.getInstallation(CLOUD_SECURITY_POSTURE_PACKAGE_NAME), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/geo_grid.test.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/geo_grid.test.tsx new file mode 100644 index 0000000000000..93e4860eb7cc2 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/geo_grid.test.tsx @@ -0,0 +1,124 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { setup, SetupResult, getProcessorValue, setupEnvironment } from './processor.helpers'; + +const GEO_GRID_TYPE = 'geo_grid'; + +describe('Processor: GeoGrid', () => { + let onUpdate: jest.Mock; + let testBed: SetupResult; + const { httpSetup } = setupEnvironment(); + + beforeAll(() => { + jest.useFakeTimers({ legacyFakeTimers: true }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(async () => { + onUpdate = jest.fn(); + + await act(async () => { + testBed = await setup(httpSetup, { + value: { + processors: [], + }, + onFlyoutOpen: jest.fn(), + onUpdate, + }); + }); + + const { component, actions } = testBed; + + component.update(); + + // Open flyout to add new processor + actions.addProcessor(); + // Add type (the other fields are not visible until a type is selected) + await actions.addProcessorType(GEO_GRID_TYPE); + }); + + test('prevents form submission if required fields are not provided', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Click submit button with only the type defined + await saveNewProcessor(); + + // Expect form error as "field" is a required parameter + expect(form.getErrorsMessages()).toEqual([ + 'A field value is required.', // "Field" input + 'A tile type value is required.', // "Tile type" input + ]); + }); + + test('saves with default parameter values', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add "field" value + form.setInputValue('fieldNameField.input', 'test_geo_grid_processor'); + + // Add "tile tyle" field + form.setSelectValue('tileTypeField', 'geohex'); + + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, GEO_GRID_TYPE); + + expect(processors[0][GEO_GRID_TYPE]).toEqual( + expect.objectContaining({ + field: 'test_geo_grid_processor', + tile_type: 'geohex', + }) + ); + }); + + test('saves with optional parameter values', async () => { + const { + actions: { saveNewProcessor }, + form, + } = testBed; + + // Add required fields + form.setInputValue('fieldNameField.input', 'test_geo_grid_processor'); + form.setSelectValue('tileTypeField', 'geohex'); + + // Add optional fields + form.setInputValue('targetField.input', 'test_target'); + form.setSelectValue('targetFormatField', 'WKT'); + form.setInputValue('parentField.input', 'parent_field'); + form.setInputValue('childrenField.input', 'children_field'); + form.setInputValue('nonChildrenField.input', 'nonchildren_field'); + form.setInputValue('precisionField.input', 'precision_field'); + + // Save the field + await saveNewProcessor(); + + const processors = getProcessorValue(onUpdate, GEO_GRID_TYPE); + + expect(processors[0][GEO_GRID_TYPE]).toEqual({ + field: 'test_geo_grid_processor', + tile_type: 'geohex', + target_field: 'test_target', + target_format: 'WKT', + parent_field: 'parent_field', + children_field: 'children_field', + non_children_field: 'nonchildren_field', + precision_field: 'precision_field', + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx index 970aaf83b5ae8..43ebf84f6ef30 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/__jest__/processors/processor.helpers.tsx @@ -190,4 +190,10 @@ type TestSubject = | 'droppableList.input-2' | 'prefixField.input' | 'suffixField.input' + | 'tileTypeField' + | 'targetFormatField' + | 'parentField.input' + | 'childrenField.input' + | 'nonChildrenField.input' + | 'precisionField.input' | 'patternDefinitionsField'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geogrid.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geogrid.tsx new file mode 100644 index 0000000000000..01fc6829101dc --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/geogrid.tsx @@ -0,0 +1,246 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer, EuiCode, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { + fieldValidators, + FIELD_TYPES, + UseField, + Field, + SelectField, +} from '../../../../../../shared_imports'; + +import { FieldNameField } from './common_fields/field_name_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldsConfig, from } from './shared'; +import { TargetField } from './common_fields/target_field'; + +const fieldsConfig: FieldsConfig = { + tile_type: { + type: FIELD_TYPES.TEXT, + defaultValue: '', + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.tileTypeFieldLabel', { + defaultMessage: 'Tile type', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.tileTypeFieldHelpText', { + defaultMessage: 'The type of tile from field.', + }), + validations: [ + { + validator: fieldValidators.emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.tileTypeRequiredError', { + defaultMessage: 'A tile type value is required.', + }) + ), + }, + ], + }, + /* Optional field config */ + parent_field: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.parentFieldLabel', { + defaultMessage: 'Parent field (optional)', + }), + helpText: ( + + ), + }, + children_field: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.childrenFieldLabel', { + defaultMessage: 'Children field (optional)', + }), + helpText: ( + + ), + }, + non_children_field: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.nonchildrenFieldLabel', { + defaultMessage: 'Non children field (optional)', + }), + helpText: ( + + ), + }, + precision_field: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.precisionFieldLabel', { + defaultMessage: 'Precision field (optional)', + }), + helpText: ( + + ), + }, + target_format: { + type: FIELD_TYPES.TEXT, + serializer: from.emptyStringToUndefined, + defaultValue: '', + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoGrid.targetFormatFieldLabel', { + defaultMessage: 'Target format (optional)', + }), + helpText: ( + {'Geo-JSON'}, + }} + /> + ), + }, +}; + +export const GeoGrid: FunctionComponent = () => { + return ( + <> + + + {'tile_type'} }} + /> + } + /> + + + {'field'} }} + /> + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts index 27f19e6c6cf9a..3512dafdd2854 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts @@ -22,6 +22,7 @@ export { Enrich } from './enrich'; export { Fail } from './fail'; export { Fingerprint } from './fingerprint'; export { Foreach } from './foreach'; +export { GeoGrid } from './geogrid'; export { GeoIP } from './geoip'; export { Grok } from './grok'; export { Gsub } from './gsub'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 3218ca456f959..75bbd764097ad 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -28,6 +28,7 @@ import { Fail, Fingerprint, Foreach, + GeoGrid, GeoIP, Grok, Gsub, @@ -374,6 +375,24 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, }), }, + geo_grid: { + FieldsComponent: GeoGrid, + docLinkPath: '/ingest-geo-grid-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.geogrid', { + defaultMessage: 'GeoGrid', + }), + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.geogrid', { + defaultMessage: + 'Converts geo-grid definitions of grid tiles or cells to regular bounding boxes or polygons which describe their shape.', + }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.geogrid', { + defaultMessage: 'Adds geo-grid data to documents based on the value of "{field}"', + values: { + field, + }, + }), + }, geoip: { FieldsComponent: GeoIP, docLinkPath: '/geoip-processor.html', diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 56dd1ee3655e5..a4f7d7661e7c6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -88,6 +88,7 @@ export const ACTION_STATUS_ROUTE = `${BASE_ENDPOINT_ROUTE}/action_status`; export const ACTION_DETAILS_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}`; export const ACTION_AGENT_FILE_INFO_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}/file/{file_id}`; export const ACTION_AGENT_FILE_DOWNLOAD_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/{action_id}/file/{file_id}/download`; +export const ACTION_STATE_ROUTE = `${BASE_ENDPOINT_ACTION_ROUTE}/state`; export const failedFleetActionErrorCode = '424'; diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx index 5bffe92e6d019..fe0889e80cda7 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/isolate_form.tsx @@ -18,6 +18,7 @@ import { EuiTextArea, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { MissingEncryptionKeyCallout } from '../../../../management/components/missing_encryption_key_callout'; import { CANCEL, COMMENT, COMMENT_PLACEHOLDER, CONFIRM } from './translations'; export interface EndpointIsolatedFormProps { @@ -42,62 +43,65 @@ export const EndpointIsolateForm = memo( ); return ( - - - -

- {hostName} }} - /> -
-

-

- {' '} - {messageAppend} -

-
-
+ <> + + + + +

+ {hostName} }} + /> +
+

+

+ {' '} + {messageAppend} +

+
+
- - - + + + - - - - - {CANCEL} - - - - - {CONFIRM} - - - - -
+ + + + + {CANCEL} + + + + + {CONFIRM} + + + + +
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx index 8226175786ae0..b1ffb60c6bbff 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/host_isolation/unisolate_form.tsx @@ -18,6 +18,7 @@ import { EuiTextArea, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { MissingEncryptionKeyCallout } from '../../../../management/components/missing_encryption_key_callout'; import { CANCEL, COMMENT, COMMENT_PLACEHOLDER, CONFIRM, UNISOLATE, ISOLATED } from './translations'; import type { EndpointIsolatedFormProps } from './isolate_form'; @@ -31,53 +32,56 @@ export const EndpointUnisolateForm = memo( ); return ( - - - -

- {hostName}, - isolated: {ISOLATED}, - unisolate: {UNISOLATE}, - }} - />{' '} - {messageAppend} -

-
-
+ <> + + + + +

+ {hostName}, + isolated: {ISOLATED}, + unisolate: {UNISOLATE}, + }} + />{' '} + {messageAppend} +

+
+
- - - + + + - - - - - {CANCEL} - - - - - {CONFIRM} - - - - -
+ + + + + {CANCEL} + + + + + {CONFIRM} + + + + +
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index fe111c13debec..b1a2d0f95417a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -35,6 +35,7 @@ import { previewRule, findRuleExceptionReferences, performBulkAction, + fetchRulesSnoozeSettings, } from './api'; const abortCtrl = new AbortController(); @@ -786,4 +787,54 @@ describe('Detections Rules API', () => { expect(result).toBe(fetchMockResult); }); }); + + describe('fetchRulesSnoozeSettings', () => { + beforeEach(() => { + fetchMock.mockClear(); + }); + + test('requests snooze settings of multiple rules by their IDs', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + filter: 'alert.id:"alert:id1" or alert.id:"alert:id2"', + }), + }) + ); + }); + + test('requests the same number of rules as the number of ids provided', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + per_page: 2, + }), + }) + ); + }); + + test('requests only snooze settings fields', () => { + fetchRulesSnoozeSettings({ ids: ['id1', 'id2'] }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + query: expect.objectContaining({ + fields: JSON.stringify([ + 'muteAll', + 'activeSnoozes', + 'isSnoozedUntil', + 'snoozeSchedule', + ]), + }), + }) + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 386dbf3c7b525..b8078421ce683 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -9,7 +9,7 @@ import type { CreateRuleExceptionListItemSchema, ExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; - +import { INTERNAL_ALERTING_API_FIND_RULES_PATH } from '@kbn/alerting-plugin/common'; import type { BulkInstallPackagesResponse } from '@kbn/fleet-plugin/common'; import { epmRouteService } from '@kbn/fleet-plugin/common'; import type { InstallPackageResponse } from '@kbn/fleet-plugin/common/types'; @@ -47,6 +47,7 @@ import type { CreateRulesProps, ExportDocumentsProps, FetchRuleProps, + FetchRuleSnoozingProps, FetchRulesProps, FetchRulesResponse, FindRulesReferencedByExceptionsProps, @@ -56,6 +57,7 @@ import type { PrePackagedRulesStatusResponse, PreviewRulesProps, Rule, + RulesSnoozeSettingsResponse, UpdateRulesProps, } from '../logic/types'; import { convertRulesFilterToKQL } from '../logic/utils'; @@ -184,6 +186,31 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => + KibanaServices.get().http.fetch( + INTERNAL_ALERTING_API_FIND_RULES_PATH, + { + method: 'GET', + query: { + filter: ids.map((x) => `alert.id:"alert:${x}"`).join(' or '), + fields: JSON.stringify(['muteAll', 'activeSnoozes', 'isSnoozedUntil', 'snoozeSchedule']), + per_page: ids.length, + }, + signal, + } + ); + export interface BulkActionSummary { failed: number; skipped: number; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts index 41bce8f0cc154..37001caf43b8c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_prebuilt_rules_mutation.ts @@ -12,6 +12,7 @@ import { createPrepackagedRules } from '../api'; import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from './use_fetch_rules_snooze_settings'; export const CREATE_PREBUILT_RULES_MUTATION_KEY = ['PUT', PREBUILT_RULES_URL]; @@ -19,6 +20,7 @@ export const useCreatePrebuiltRulesMutation = ( options?: UseMutationOptions ) => { const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); + const invalidateFetchRulesSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); const invalidatePrePackagedRulesStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); @@ -30,6 +32,7 @@ export const useCreatePrebuiltRulesMutation = ( // the number of rules might change after the installation invalidatePrePackagedRulesStatus(); invalidateFindRulesQuery(); + invalidateFetchRulesSnoozeSettings(); invalidateFetchRuleManagementFilters(); if (options?.onSettled) { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts new file mode 100644 index 0000000000000..bdc101fe18644 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts @@ -0,0 +1,62 @@ +/* + * 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 { INTERNAL_ALERTING_API_FIND_RULES_PATH } from '@kbn/alerting-plugin/common'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import type { RuleSnoozeSettings } from '../../logic'; +import { fetchRulesSnoozeSettings } from '../api'; +import { DEFAULT_QUERY_OPTIONS } from './constants'; + +const FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY = ['GET', INTERNAL_ALERTING_API_FIND_RULES_PATH]; + +/** + * A wrapper around useQuery provides default values to the underlying query, + * like query key, abortion signal. + * + * @param queryArgs - fetch rule snoozing settings ids + * @param queryOptions - react-query options + * @returns useQuery result + */ +export const useFetchRulesSnoozeSettings = ( + ids: string[], + queryOptions?: UseQueryOptions +) => { + return useQuery( + [...FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, ...ids], + async ({ signal }) => { + const response = await fetchRulesSnoozeSettings({ ids, signal }); + + return response.data; + }, + { + ...DEFAULT_QUERY_OPTIONS, + ...queryOptions, + } + ); +}; + +/** + * We should use this hook to invalidate the cache. For example, rule + * snooze modification should lead to cache invalidation. + * + * @returns A rules cache invalidation callback + */ +export const useInvalidateFetchRulesSnoozeSettingsQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + /** + * Invalidate all queries that start with FIND_RULES_QUERY_KEY. This + * includes the in-memory query cache and paged query cache. + */ + queryClient.invalidateQueries(FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, { + refetchType: 'active', + }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx new file mode 100644 index 0000000000000..f2a06cd5475e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge.tsx @@ -0,0 +1,59 @@ +/* + * 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { useUserData } from '../../../detections/components/user_info'; +import { hasUserCRUDPermission } from '../../../common/utils/privileges'; +import { useKibana } from '../../../common/lib/kibana'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../api/hooks/use_fetch_rules_snooze_settings'; +import { useRulesTableContext } from '../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import * as i18n from './translations'; + +interface RuleSnoozeBadgeProps { + id: string; // Rule SO's id (not ruleId) +} + +export function RuleSnoozeBadge({ id }: RuleSnoozeBadgeProps): JSX.Element { + const RulesListNotifyBadge = useKibana().services.triggersActionsUi.getRulesListNotifyBadge; + const [{ canUserCRUD }] = useUserData(); + const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); + const { + state: { rulesSnoozeSettings }, + } = useRulesTableContext(); + const invalidateFetchRuleSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); + const rule = useMemo(() => { + const ruleSnoozeSettings = rulesSnoozeSettings.data[id]; + + return { + id: ruleSnoozeSettings?.id ?? '', + muteAll: ruleSnoozeSettings?.mute_all ?? false, + activeSnoozes: ruleSnoozeSettings?.active_snoozes ?? [], + isSnoozedUntil: ruleSnoozeSettings?.is_snoozed_until + ? new Date(ruleSnoozeSettings.is_snoozed_until) + : undefined, + snoozeSchedule: ruleSnoozeSettings?.snooze_schedule, + isEditable: hasCRUDPermissions, + }; + }, [id, rulesSnoozeSettings, hasCRUDPermissions]); + + if (rulesSnoozeSettings.isError) { + return ( + + + + ); + } + + return ( + + ); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts new file mode 100644 index 0000000000000..1b98a9c6212eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const UNABLE_TO_FETCH_RULE_SNOOZE_SETTINGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleManagement.ruleSnoozeBadge.error.unableToFetch', + { + defaultMessage: 'Unable to fetch snooze settings', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index 0f93d66efbf6f..ca71fa2680f17 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; +import type { RuleSnooze } from '@kbn/alerting-plugin/common'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; import { RiskScore, @@ -217,6 +218,18 @@ export interface FetchRulesProps { signal?: AbortSignal; } +export interface RuleSnoozeSettings { + id: string; + mute_all: boolean; + snooze_schedule?: RuleSnooze; + active_snoozes?: string[]; + is_snoozed_until?: string; +} + +export interface RulesSnoozeSettingsResponse { + data: RuleSnoozeSettings[]; +} + export type SortingOptions = t.TypeOf; export const SortingOptions = t.type({ field: FindRulesSortField, @@ -244,6 +257,11 @@ export interface FetchRuleProps { signal?: AbortSignal; } +export interface FetchRuleSnoozingProps { + ids: string[]; + signal?: AbortSignal; +} + export interface BasicFetchProps { signal: AbortSignal; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx index 688b90c860ceb..8ab84b2e60a60 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/__mocks__/rules_table_context.tsx @@ -12,6 +12,11 @@ export const useRulesTableContextMock = { create: (): jest.Mocked => ({ state: { rules: [], + rulesSnoozeSettings: { + data: {}, + isLoading: false, + isError: false, + }, pagination: { page: 1, perPage: 20, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index cb2eb08c5381b..22a3af8ff0814 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -9,7 +9,9 @@ import { renderHook } from '@testing-library/react-hooks'; import type { PropsWithChildren } from 'react'; import React from 'react'; import { useUiSetting$ } from '../../../../../common/lib/kibana'; +import type { Rule, RuleSnoozeSettings } from '../../../../rule_management/logic/types'; import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; +import { useFetchRulesSnoozeSettings } from '../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; import type { RulesTableState } from './rules_table_context'; import { RulesTableContextProvider, useRulesTableContext } from './rules_table_context'; import { @@ -23,22 +25,51 @@ import { useRulesTableSavedState } from './use_rules_table_saved_state'; jest.mock('../../../../../common/lib/kibana'); jest.mock('../../../../rule_management/logic/use_find_rules'); +jest.mock('../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'); jest.mock('./use_rules_table_saved_state'); -function renderUseRulesTableContext( - savedState: ReturnType -): RulesTableState { +function renderUseRulesTableContext({ + rules, + rulesSnoozeSettings, + savedState, +}: { + rules?: Rule[] | Error; + rulesSnoozeSettings?: RuleSnoozeSettings[] | Error; + savedState?: ReturnType; +}): RulesTableState { (useFindRules as jest.Mock).mockReturnValue({ - data: { rules: [], total: 0 }, + data: rules instanceof Error || !rules ? undefined : { rules, total: rules?.length }, refetch: jest.fn(), dataUpdatedAt: 0, - isFetched: false, - isFetching: false, - isLoading: false, + isFetched: !!rules, + isFetching: !rules, + isLoading: !rules, isRefetching: false, + isError: rules instanceof Error, + }); + (useFetchRulesSnoozeSettings as jest.Mock).mockReturnValue({ + data: rulesSnoozeSettings instanceof Error ? undefined : rulesSnoozeSettings, + isError: rulesSnoozeSettings instanceof Error, }); (useUiSetting$ as jest.Mock).mockReturnValue([{ on: false, value: 0, idleTimeout: 0 }]); - (useRulesTableSavedState as jest.Mock).mockReturnValue(savedState); + (useRulesTableSavedState as jest.Mock).mockReturnValue( + savedState ?? { + filter: { + searchTerm: undefined, + source: undefined, + tags: undefined, + enabled: undefined, + }, + sorting: { + field: undefined, + order: undefined, + }, + pagination: { + page: undefined, + perPage: undefined, + }, + } + ); const wrapper = ({ children }: PropsWithChildren<{}>) => ( {children} @@ -56,19 +87,22 @@ describe('RulesTableContextProvider', () => { describe('persisted state', () => { it('restores persisted rules table state', () => { const state = renderUseRulesTableContext({ - filter: { - searchTerm: 'test', - source: RuleSource.Custom, - tags: ['test'], - enabled: true, - }, - sorting: { - field: 'name', - order: 'asc', - }, - pagination: { - page: 2, - perPage: 10, + rules: [], + savedState: { + filter: { + searchTerm: 'test', + source: RuleSource.Custom, + tags: ['test'], + enabled: true, + }, + sorting: { + field: 'name', + order: 'asc', + }, + pagination: { + page: 2, + perPage: 10, + }, }, }); @@ -112,4 +146,94 @@ describe('RulesTableContextProvider', () => { expect(state.isDefault).toBeTruthy(); }); }); + + describe('state', () => { + describe('rules', () => { + it('returns an empty array while loading', () => { + const state = renderUseRulesTableContext({ + rules: undefined, + }); + + expect(state.rules).toEqual([]); + }); + + it('returns an empty array upon error', () => { + const state = renderUseRulesTableContext({ + rules: new Error('some error'), + }); + + expect(state.rules).toEqual([]); + }); + + it('returns rules while snooze settings are not loaded yet', () => { + const state = renderUseRulesTableContext({ + rules: [{ name: 'rule 1' }, { name: 'rule 2' }] as Rule[], + rulesSnoozeSettings: undefined, + }); + + expect(state.rules).toEqual([{ name: 'rule 1' }, { name: 'rule 2' }]); + }); + + it('returns rules even if snooze settings failed to be loaded', () => { + const state = renderUseRulesTableContext({ + rules: [{ name: 'rule 1' }, { name: 'rule 2' }] as Rule[], + rulesSnoozeSettings: new Error('some error'), + }); + + expect(state.rules).toEqual([{ name: 'rule 1' }, { name: 'rule 2' }]); + }); + + it('returns rules after snooze settings loaded', () => { + const state = renderUseRulesTableContext({ + rules: [ + { id: '1', name: 'rule 1' }, + { id: '2', name: 'rule 2' }, + ] as Rule[], + rulesSnoozeSettings: [ + { id: '1', mute_all: true, snooze_schedule: [] }, + { id: '2', mute_all: false, snooze_schedule: [] }, + ], + }); + + expect(state.rules).toEqual([ + { + id: '1', + name: 'rule 1', + }, + { + id: '2', + name: 'rule 2', + }, + ]); + }); + }); + + describe('rules snooze settings', () => { + it('returns snooze settings', () => { + const state = renderUseRulesTableContext({ + rules: [ + { id: '1', name: 'rule 1' }, + { id: '2', name: 'rule 2' }, + ] as Rule[], + rulesSnoozeSettings: [ + { id: '1', mute_all: true, snooze_schedule: [] }, + { id: '2', mute_all: false, snooze_schedule: [] }, + ], + }); + + expect(state.rulesSnoozeSettings.data).toEqual({ + '1': { + id: '1', + mute_all: true, + snooze_schedule: [], + }, + '2': { + id: '2', + mute_all: false, + snooze_schedule: [], + }, + }); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx index f6ec9a3714f66..fa2c64f01d2d4 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.tsx @@ -15,6 +15,7 @@ import React, { useRef, useState, } from 'react'; +import { useFetchRulesSnoozeSettings } from '../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; import { DEFAULT_RULES_TABLE_REFRESH_SETTING } from '../../../../../../common/constants'; import { invariant } from '../../../../../../common/utils/invariant'; import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state'; @@ -24,6 +25,7 @@ import type { FilterOptions, PaginationOptions, Rule, + RuleSnoozeSettings, SortingOptions, } from '../../../../rule_management/logic/types'; import { useFindRules } from '../../../../rule_management/logic/use_find_rules'; @@ -37,6 +39,12 @@ import { import { RuleSource } from './rules_table_saved_state'; import { useRulesTableSavedState } from './use_rules_table_saved_state'; +interface RulesSnoozeSettings { + data: Record; // The key is a rule SO's id (not ruleId) + isLoading: boolean; + isError: boolean; +} + export interface RulesTableState { /** * Rules to display (sorted and paginated in case of in-memory) @@ -106,6 +114,10 @@ export interface RulesTableState { * Whether the state has its default value */ isDefault: boolean; + /** + * Rules snooze settings for the current rules + */ + rulesSnoozeSettings: RulesSnoozeSettings; } export type LoadingRuleAction = @@ -274,9 +286,27 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide } ); + // Fetch rules snooze settings + const { + data: rulesSnoozeSettings, + isLoading: isSnoozeSettingsLoading, + isError: isSnoozeSettingsFetchError, + refetch: refetchSnoozeSettings, + } = useFetchRulesSnoozeSettings( + rules.map((x) => x.id), + { enabled: rules.length > 0 } + ); + + const refetchRulesAndSnoozeSettings = useCallback(async () => { + const response = await refetch(); + await refetchSnoozeSettings(); + + return response; + }, [refetch, refetchSnoozeSettings]); + const actions = useMemo( () => ({ - reFetchRules: refetch, + reFetchRules: refetchRulesAndSnoozeSettings, setFilterOptions: handleFilterOptionsChange, setIsAllSelected, setIsRefreshOn, @@ -290,7 +320,7 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide clearFilters, }), [ - refetch, + refetchRulesAndSnoozeSettings, handleFilterOptionsChange, setIsAllSelected, setIsRefreshOn, @@ -305,10 +335,22 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide ] ); - const providerValue = useMemo( - () => ({ + const providerValue = useMemo(() => { + const rulesSnoozeSettingsMap = + rulesSnoozeSettings?.reduce((map, snoozeSettings) => { + map[snoozeSettings.id] = snoozeSettings; + + return map; + }, {} as Record) ?? {}; + + return { state: { rules, + rulesSnoozeSettings: { + data: rulesSnoozeSettingsMap, + isLoading: isSnoozeSettingsLoading, + isError: isSnoozeSettingsFetchError, + }, pagination: { page, perPage, @@ -335,29 +377,31 @@ export const RulesTableContextProvider = ({ children }: RulesTableContextProvide }), }, actions, - }), - [ - rules, - page, - perPage, - total, - filterOptions, - isPreflightInProgress, - isActionInProgress, - isAllSelected, - isFetched, - isFetching, - isLoading, - isRefetching, - isRefreshOn, - dataUpdatedAt, - loadingRules.ids, - loadingRules.action, - selectedRuleIds, - sortingOptions, - actions, - ] - ); + }; + }, [ + rules, + rulesSnoozeSettings, + isSnoozeSettingsLoading, + isSnoozeSettingsFetchError, + page, + perPage, + total, + filterOptions, + isPreflightInProgress, + isActionInProgress, + isAllSelected, + isFetched, + isFetching, + isLoading, + isRefetching, + isRefreshOn, + dataUpdatedAt, + loadingRules.ids, + loadingRules.action, + selectedRuleIds, + sortingOptions, + actions, + ]); return {children}; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index 1296e9728d2e6..e20a2f2c70e4f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -22,6 +22,7 @@ import type { } from '../../../../../common/detection_engine/rule_monitoring'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -106,6 +107,19 @@ const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): Ta ); }; +const useRuleSnoozeColumn = (): TableColumn => { + return useMemo( + () => ({ + field: 'snooze', + name: i18n.COLUMN_SNOOZE, + render: (_, rule: Rule) => , + width: '100px', + sortable: false, + }), + [] + ); +}; + export const RuleLink = ({ name, id }: Pick) => { return ( @@ -248,6 +262,7 @@ export const useRulesColumns = ({ isLoadingJobs, mlJobs, }); + const snoozeColumn = useRuleSnoozeColumn(); return useMemo( () => [ @@ -317,6 +332,7 @@ export const useRulesColumns = ({ width: '18%', truncateText: true, }, + snoozeColumn, enabledColumn, ...(hasCRUDPermissions ? [actionsColumn] : []), ], @@ -324,6 +340,7 @@ export const useRulesColumns = ({ actionsColumn, enabledColumn, executionStatusColumn, + snoozeColumn, hasCRUDPermissions, ruleNameColumn, showRelatedIntegrations, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 9511138f78f34..487a6176969d1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -553,6 +553,13 @@ export const COLUMN_ENABLE = i18n.translate( } ); +export const COLUMN_SNOOZE = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.columns.snoozeTitle', + { + defaultMessage: 'Notify', + } +); + export const COLUMN_INDEXING_TIMES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.indexingTimes', { diff --git a/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/index.ts b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/index.ts new file mode 100644 index 0000000000000..c580eeda82d82 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/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 { MissingEncryptionKeyCallout } from './missing_encryption_key_callout'; diff --git a/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.test.tsx b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.test.tsx new file mode 100644 index 0000000000000..eeff557fb0131 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.test.tsx @@ -0,0 +1,88 @@ +/* + * 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 { ACTION_STATE_ROUTE } from '../../../../common/endpoint/constants'; +import { act, fireEvent } from '@testing-library/react'; +import React from 'react'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { policyListApiPathHandlers } from '../../pages/policy/store/test_mock_utils'; +import { MissingEncryptionKeyCallout } from './missing_encryption_key_callout'; + +describe('Missing encryption key callout', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + let asyncActions: Promise = Promise.resolve(); + const sleep = (ms = 100) => new Promise((wakeup) => setTimeout(wakeup, ms)); + + const policyListApiHandlers = policyListApiPathHandlers(); + + const apiReturnsEncryptionKeyIsSet = (canEncrypt: boolean) => { + mockedContext.coreStart.http.get.mockImplementation((...args) => { + const [path] = args; + if (typeof path === 'string') { + // GET datasouce + if (path === ACTION_STATE_ROUTE) { + asyncActions = asyncActions.then(async (): Promise => sleep()); + return Promise.resolve({ + data: { canEncrypt }, + }); + } + + // Get action state + // Used in tests that route back to the list + if (policyListApiHandlers[path]) { + asyncActions = asyncActions.then(async () => sleep()); + return Promise.resolve(policyListApiHandlers[path]()); + } + } + + return Promise.reject(new Error(`unknown API call (not MOCKED): ${path}`)); + }); + }; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + render = () => (renderResult = mockedContext.render()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be visible when encryption key not set', async () => { + apiReturnsEncryptionKeyIsSet(false); + render(); + await asyncActions; + const callout = renderResult.queryByTestId('missingEncryptionKeyCallout'); + expect(callout).toBeTruthy(); + }); + + it('should not be visible when encryption key is set', async () => { + apiReturnsEncryptionKeyIsSet(true); + render(); + await asyncActions; + const callout = renderResult.queryByTestId('missingEncryptionKeyCallout'); + expect(callout).toBeFalsy(); + }); + + it('should be able to dismiss when visible', async () => { + apiReturnsEncryptionKeyIsSet(false); + render(); + await asyncActions; + let callout = renderResult.queryByTestId('missingEncryptionKeyCallout'); + expect(callout).toBeTruthy(); + + act(() => { + fireEvent.click(renderResult.getByTestId('dismissEncryptionKeyCallout')); + }); + + callout = renderResult.queryByTestId('missingEncryptionKeyCallout'); + expect(callout).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.tsx b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.tsx new file mode 100644 index 0000000000000..6524f81f23c8b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/missing_encryption_key_callout/missing_encryption_key_callout.tsx @@ -0,0 +1,64 @@ +/* + * 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, { memo, useState, useCallback } from 'react'; +import { EuiCallOut, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { useGetActionState } from '../../hooks'; + +export const MissingEncryptionKeyCallout = memo(() => { + const { data: encryptionKeyState } = useGetActionState(); + const [calloutDismiss, setCalloutDismiss] = useState(false); + + const onClickDismissButton = useCallback(() => setCalloutDismiss(true), []); + + if (!encryptionKeyState) { + return null; + } + + if (calloutDismiss || encryptionKeyState.data.canEncrypt === true) { + return null; + } + + return ( + <> + +
+ +
+ + + + +
+ + + ); +}); + +MissingEncryptionKeyCallout.displayName = 'MissingEncryptionKeyCallout'; diff --git a/x-pack/plugins/security_solution/public/management/hooks/index.ts b/x-pack/plugins/security_solution/public/management/hooks/index.ts index b439bcffb8874..5afa3cd5457f0 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/index.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/index.ts @@ -8,3 +8,4 @@ export { useGetEndpointDetails } from './endpoint/use_get_endpoint_details'; export { useWithShowEndpointResponder } from './use_with_show_endpoint_responder'; export { useGetEndpointActionList } from './response_actions/use_get_endpoint_action_list'; +export { useGetActionState } from './use_get_action_state'; diff --git a/x-pack/plugins/security_solution/public/management/hooks/use_get_action_state.ts b/x-pack/plugins/security_solution/public/management/hooks/use_get_action_state.ts new file mode 100644 index 0000000000000..9b737abfc7ece --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/hooks/use_get_action_state.ts @@ -0,0 +1,31 @@ +/* + * 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 { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import type { IHttpFetchError } from '@kbn/core-http-browser'; +import { ACTION_STATE_ROUTE } from '../../../common/endpoint/constants'; +import { useHttp } from '../../common/lib/kibana'; + +interface GetActionStateResponse { + data: { + canEncrypt?: boolean; + }; +} +/** + * Get info for action state + */ +export const useGetActionState = (): UseQueryResult => { + const http = useHttp(); + + return useQuery({ + queryKey: ['get-action-state'], + queryFn: () => { + return http.get(ACTION_STATE_ROUTE); + }, + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/hooks/use_with_show_endpoint_responder.tsx b/x-pack/plugins/security_solution/public/management/hooks/use_with_show_endpoint_responder.tsx index c5dd32bacfb8f..26a0763ae6124 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/use_with_show_endpoint_responder.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/use_with_show_endpoint_responder.tsx @@ -16,6 +16,7 @@ import { } from '../components/endpoint_responder'; import { useConsoleManager } from '../components/console'; import type { HostMetadata } from '../../../common/endpoint/types'; +import { MissingEncryptionKeyCallout } from '../components/missing_encryption_key_callout'; type ShowEndpointResponseActionsConsole = (endpointMetadata: HostMetadata) => void; @@ -58,7 +59,12 @@ export const useWithShowEndpointResponder = (): ShowEndpointResponseActionsConso TitleComponent: () => , }, PageTitleComponent: () => <>{RESPONDER_PAGE_TITLE}, - PageBodyComponent: () => , + PageBodyComponent: () => ( + <> + + + + ), ActionComponents: endpointPrivileges.canReadActionsLogManagement ? [ActionLogButton] : undefined, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts index 6bba9bc38c962..1f96cb4dff64a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/index.ts @@ -11,6 +11,7 @@ import { registerActionDetailsRoutes } from './details'; import type { SecuritySolutionPluginRouter } from '../../../types'; import type { EndpointAppContext } from '../../types'; import { registerActionStatusRoutes } from './status'; +import { registerActionStateRoutes } from './state'; import { registerActionAuditLogRoutes } from './audit_log'; import { registerActionListRoutes } from './list'; import { registerResponseActionRoutes } from './response_actions'; @@ -19,9 +20,11 @@ import { registerResponseActionRoutes } from './response_actions'; export function registerActionRoutes( router: SecuritySolutionPluginRouter, - endpointContext: EndpointAppContext + endpointContext: EndpointAppContext, + canEncrypt?: boolean ) { registerActionStatusRoutes(router, endpointContext); + registerActionStateRoutes(router, endpointContext, canEncrypt); registerActionAuditLogRoutes(router, endpointContext); registerActionListRoutes(router, endpointContext); registerActionDetailsRoutes(router, endpointContext); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.test.ts new file mode 100644 index 0000000000000..9612eab2c2821 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.test.ts @@ -0,0 +1,133 @@ +/* + * 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 { ScopedClusterClientMock } from '@kbn/core/server/mocks'; +import { loggingSystemMock, httpServerMock, httpServiceMock } from '@kbn/core/server/mocks'; +import type { KibanaResponseFactory, SavedObjectsClientContract } from '@kbn/core/server'; + +import { + createMockEndpointAppContextServiceStartContract, + createRouteHandlerContext, +} from '../../mocks'; +import type { EndpointAuthz } from '../../../../common/endpoint/types/authz'; + +import { createMockConfig } from '../../../lib/detection_engine/routes/__mocks__'; + +import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks'; + +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { parseExperimentalConfigValue } from '../../../../common/experimental_features'; + +import { registerActionStateRoutes } from './state'; +import type { RouterMock } from '@kbn/core-http-router-server-mocks'; +import { ACTION_STATE_ROUTE } from '../../../../common/endpoint/constants'; + +interface CallRouteInterface { + authz?: Partial; +} + +describe('when calling the Action state route handler', () => { + let mockScopedEsClient: ScopedClusterClientMock; + let mockSavedObjectClient: jest.Mocked; + let mockResponse: jest.Mocked; + let callRoute: ( + routerMock: RouterMock, + routePrefix: string, + opts: CallRouteInterface, + indexExists?: { endpointDsExists: boolean } + ) => Promise; + + beforeEach(() => { + const startContract = createMockEndpointAppContextServiceStartContract(); + mockResponse = httpServerMock.createResponseFactory(); + // define a convenience function to execute an API call for a given route + callRoute = async ( + routerMock: RouterMock, + routePrefix: string, + { authz = {} }: CallRouteInterface + ): Promise => { + const superUser = { + username: 'superuser', + roles: ['superuser'], + }; + (startContract.security.authc.getCurrentUser as jest.Mock).mockImplementationOnce( + () => superUser + ); + + const ctx = createRouteHandlerContext(mockScopedEsClient, mockSavedObjectClient); + + ctx.securitySolution.getEndpointAuthz.mockResolvedValue( + getEndpointAuthzInitialStateMock(authz) + ); + + const mockRequest = httpServerMock.createKibanaRequest(); + const [, routeHandler] = routerMock.get.mock.calls.find(([{ path }]) => + path.startsWith(routePrefix) + )!; + + await routeHandler(ctx, mockRequest, mockResponse); + }; + }); + describe('with having right privileges', () => { + it.each([[true], [false]])( + 'when can encrypt is set to %s it returns proper value', + async (canEncrypt) => { + const routerMock: RouterMock = httpServiceMock.createRouter(); + const endpointAppContextService = new EndpointAppContextService(); + registerActionStateRoutes( + routerMock, + { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue( + createMockConfig().enableExperimental + ), + }, + canEncrypt + ); + + await callRoute(routerMock, ACTION_STATE_ROUTE, { + authz: { canIsolateHost: true }, + }); + + expect(mockResponse.ok).toHaveBeenCalledWith({ body: { data: { canEncrypt } } }); + } + ); + }); + describe('without having right privileges', () => { + it('it returns unauthorized error', async () => { + const routerMock: RouterMock = httpServiceMock.createRouter(); + const endpointAppContextService = new EndpointAppContextService(); + registerActionStateRoutes( + routerMock, + { + logFactory: loggingSystemMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(createMockConfig()), + experimentalFeatures: parseExperimentalConfigValue(createMockConfig().enableExperimental), + }, + true + ); + + await callRoute(routerMock, ACTION_STATE_ROUTE, { + authz: { + canIsolateHost: false, + canUnIsolateHost: false, + canKillProcess: false, + canSuspendProcess: false, + canGetRunningProcesses: false, + canAccessResponseConsole: false, + canWriteExecuteOperations: false, + canWriteFileOperations: false, + }, + }); + + expect(mockResponse.forbidden).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.ts new file mode 100644 index 0000000000000..f461b144663ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/state.ts @@ -0,0 +1,60 @@ +/* + * 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 { RequestHandler } from '@kbn/core/server'; +import { ACTION_STATE_ROUTE } from '../../../../common/endpoint/constants'; +import type { + SecuritySolutionPluginRouter, + SecuritySolutionRequestHandlerContext, +} from '../../../types'; +import type { EndpointAppContext } from '../../types'; +import { withEndpointAuthz } from '../with_endpoint_authz'; + +/** + * Registers routes for checking state of actions routes + */ +export function registerActionStateRoutes( + router: SecuritySolutionPluginRouter, + endpointContext: EndpointAppContext, + canEncrypt?: boolean +) { + router.get( + { + path: ACTION_STATE_ROUTE, + validate: false, + options: { authRequired: true, tags: ['access:securitySolution'] }, + }, + withEndpointAuthz( + { + any: [ + 'canIsolateHost', + 'canUnIsolateHost', + 'canKillProcess', + 'canSuspendProcess', + 'canGetRunningProcesses', + 'canAccessResponseConsole', + 'canWriteExecuteOperations', + 'canWriteFileOperations', + ], + }, + endpointContext.logFactory.get('actionState'), + getActionStateRequestHandler(canEncrypt) + ) + ); +} + +export const getActionStateRequestHandler = function ( + canEncrypt?: boolean +): RequestHandler { + return async (_, __, res) => { + return res.ok({ + body: { + data: { canEncrypt }, + }, + }); + }; +}; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/suggestions/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/suggestions/index.ts index cd18a61368ef7..ca16294c552af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/suggestions/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/suggestions/index.ts @@ -39,6 +39,7 @@ export function registerEndpointSuggestionsRoutes( { path: SUGGESTIONS_ROUTE, validate: EndpointSuggestionsSchema, + options: { authRequired: true, tags: ['access:securitySolution'] }, }, withEndpointAuthz( { any: ['canWriteEventFilters'] }, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 1a01113a20a4d..bde9725934721 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -307,7 +307,11 @@ export class Plugin implements ISecuritySolutionPlugin { ); registerLimitedConcurrencyRoutes(core); registerPolicyRoutes(router, this.endpointContext); - registerActionRoutes(router, this.endpointContext); + registerActionRoutes( + router, + this.endpointContext, + plugins.encryptedSavedObjects?.canEncrypt === true + ); const ruleTypes = [ LEGACY_NOTIFICATIONS_ID, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 18dc82ce2bcd2..08713b54b4a6d 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -6817,7 +6817,6 @@ "visualizations.missedDataView.errorMessage": "Impossible de trouver {type} : {id}", "visualizations.newChart.conditionalMessage.newLibrary": "Passer à la bibliothèque {type} dans {link}", "visualizations.newGaugeChart.notificationMessage": "La nouvelle bibliothèque de graphiques de jauge ne prend pas encore en charge l'agrégation de graphiques fractionnés. {conditionalMessage}", - "visualizations.newHeatmapChart.notificationMessage": "La nouvelle bibliothèque de graphiques de cartes thermiques ne prend pas encore en charge l'agrégation de graphiques fractionnés. {conditionalMessage}", "visualizations.newVisWizard.newVisTypeTitle": "Nouveau {visTypeName}", "visualizations.newVisWizard.resultsFound": "{resultCount, plural, one {type trouvé} other {types trouvés}}", "visualizations.noMatchRoute.bannerText": "L'application Visualize ne reconnaît pas cet itinéraire : {route}.", @@ -37904,7 +37903,6 @@ "visTypeHeatmap.scaleTypes.linearText": "Linéaire", "visTypeHeatmap.scaleTypes.logText": "Logarithmique", "visTypeHeatmap.scaleTypes.squareRootText": "Racine carrée", - "visTypeHeatmap.splitTitle.tooltip": "l'agrégation de graphique divisé n’est pas encore compatible avec la nouvelle bibliothèque de graphiques. Veuillez activer le paramètre avancé de la bibliothèque de graphiques héritée pour les cartes thermiques afin d'utiliser l'agrégation de graphique divisé.", "visTypeMarkdown.function.font.help": "Paramètres de police.", "visTypeMarkdown.function.help": "Visualisation Markdown", "visTypeMarkdown.function.markdown.help": "Markdown à rendre", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a4efde2e55050..6cf3ef40920a4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6818,7 +6818,6 @@ "visualizations.missedDataView.errorMessage": "{type}が見つかりませんでした:{id}", "visualizations.newChart.conditionalMessage.newLibrary": "{link}で{type}ライブラリに切り替える", "visualizations.newGaugeChart.notificationMessage": "新しいゲージグラフライブラリはまだ分割グラフアグリゲーションをサポートしていません。{conditionalMessage}", - "visualizations.newHeatmapChart.notificationMessage": "新しいヒートマップグラフライブラリはまだ分割グラフアグリゲーションをサポートしていません。{conditionalMessage}", "visualizations.newVisWizard.newVisTypeTitle": "新規{visTypeName}", "visualizations.newVisWizard.resultsFound": "{resultCount, plural, other {タイプ}}が見つかりました", "visualizations.noMatchRoute.bannerText": "Visualizeアプリケーションはこのルートを認識できません。{route}", @@ -37872,7 +37871,6 @@ "visTypeHeatmap.scaleTypes.linearText": "線形", "visTypeHeatmap.scaleTypes.logText": "ログ", "visTypeHeatmap.scaleTypes.squareRootText": "平方根", - "visTypeHeatmap.splitTitle.tooltip": "分割グラフアグリゲーションは、新しいグラフライブラリでまだサポートされていません。分割グラフアグリゲーションを使用するには、ヒートマップのレガシーグラフライブラリと詳細設定を有効にしてください。", "visTypeMarkdown.function.font.help": "フォント設定です。", "visTypeMarkdown.function.help": "マークダウンビジュアライゼーション", "visTypeMarkdown.function.markdown.help": "レンダリングするマークダウン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d67deac8d9f9a..233b3f3b33119 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6818,7 +6818,6 @@ "visualizations.missedDataView.errorMessage": "找不到 {type}:{id}", "visualizations.newChart.conditionalMessage.newLibrary": "切换到{link}中的{type}库", "visualizations.newGaugeChart.notificationMessage": "新的仪表盘图表库尚不支持拆分图表聚合。{conditionalMessage}", - "visualizations.newHeatmapChart.notificationMessage": "新的热图图表库尚不支持拆分图表聚合。{conditionalMessage}", "visualizations.newVisWizard.newVisTypeTitle": "新建{visTypeName}", "visualizations.newVisWizard.resultsFound": "{resultCount, plural, other {类型}}已找到", "visualizations.noMatchRoute.bannerText": "Visualize 应用程序无法识别此路由:{route}。", @@ -37899,7 +37898,6 @@ "visTypeHeatmap.scaleTypes.linearText": "线性", "visTypeHeatmap.scaleTypes.logText": "对数", "visTypeHeatmap.scaleTypes.squareRootText": "平方根", - "visTypeHeatmap.splitTitle.tooltip": "新图表库尚不支持拆分图表聚合。请启用热图旧版图表库高级设置以使用拆分图表聚合。", "visTypeMarkdown.function.font.help": "字体设置。", "visTypeMarkdown.function.help": "Markdown 可视化", "visTypeMarkdown.function.markdown.help": "要渲染的 Markdown", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx index d4cd600f98ee5..238a88ba951f1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/notify_badge/notify_badge_with_api.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useKibana } from '../../../../../common/lib/kibana'; import { SnoozeSchedule } from '../../../../../types'; import { loadRule } from '../../../../lib/rule_api/get_rule'; @@ -24,6 +24,13 @@ export const RulesListNotifyBadgeWithApi: React.FunctionComponent< const [ruleSnoozeInfo, setRuleSnoozeInfo] = useState(rule); + // This helps to fix problems related to rule prop updates. As component handles the loading state via isLoading prop + // rule prop is obviously not ready atm so when it's ready ruleSnoozeInfo won't be updated without useEffect so + // incorrect state will be shown. + useEffect(() => { + setRuleSnoozeInfo(rule); + }, [rule]); + const onSnoozeRule = useCallback( (snoozeSchedule: SnoozeSchedule) => { return snoozeRuleApi({ http, id: ruleSnoozeInfo.id, snoozeSchedule }); diff --git a/x-pack/test/functional/apps/visualize/telemetry.ts b/x-pack/test/functional/apps/visualize/telemetry.ts index 20620f20e2c97..6bf1ae510a5f4 100644 --- a/x-pack/test/functional/apps/visualize/telemetry.ts +++ b/x-pack/test/functional/apps/visualize/telemetry.ts @@ -32,6 +32,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.load( `x-pack/test/functional/fixtures/kbn_archiver/dashboard/with_by_value_visualizations` ); + await kibanaServer.uiSettings.update({ + 'histogram:maxBars': 100, + 'visualization:visualize:legacyHeatmapChartsLibrary': true, + }); await retry.try(async () => { await PageObjects.common.navigateToApp('dashboard'); @@ -49,6 +53,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.unload( 'x-pack/test/functional/fixtures/kbn_archiver/dashboard/with_by_value_visualizations' ); + await kibanaServer.uiSettings.update({ + 'histogram:maxBars': 1000, + 'visualization:visualize:legacyHeatmapChartsLibrary': false, + }); await kibanaServer.savedObjects.cleanStandardList(); });