From 6808f826625f7f901099df6360a2f5e7edc21ab4 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Wed, 25 Sep 2024 12:38:49 +0300 Subject: [PATCH 1/8] [Discover] Add "Shift + Select" functionality to Discover grid (#193619) - Closes https://github.com/elastic/kibana/issues/192366 ## Summary This PR allows to select/deselect multiple rows by holding SHIFT key when toggling row checkboxes. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --- .../__mocks__/table_context.ts | 1 + .../src/components/data_table.tsx | 10 +++- .../data_table_document_selection.tsx | 10 ++-- .../src/hooks/use_selected_docs.test.ts | 28 +++++++++- .../src/hooks/use_selected_docs.ts | 47 +++++++++++++++- .../_data_grid_row_selection.ts | 54 +++++++++++++++++++ test/functional/services/data_grid.ts | 13 ++++- 7 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/kbn-unified-data-table/__mocks__/table_context.ts b/packages/kbn-unified-data-table/__mocks__/table_context.ts index a253b02ab5838..739e04a954e07 100644 --- a/packages/kbn-unified-data-table/__mocks__/table_context.ts +++ b/packages/kbn-unified-data-table/__mocks__/table_context.ts @@ -69,6 +69,7 @@ export function buildSelectedDocsState(selectedDocIds: string[]): UseSelectedDoc selectedDocsCount: selectedDocsSet.size, docIdsInSelectionOrder: selectedDocIds, toggleDocSelection: jest.fn(), + toggleMultipleDocsSelection: jest.fn(), selectAllDocs: jest.fn(), selectMoreDocs: jest.fn(), deselectSomeDocs: jest.fn(), diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 7eeab83fdecb7..662c8526dd567 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -497,8 +497,14 @@ export const UnifiedDataTable = ({ const [isCompareActive, setIsCompareActive] = useState(false); const displayedColumns = getDisplayedColumns(columns, dataView); const defaultColumns = displayedColumns.includes('_source'); - const docMap = useMemo(() => new Map(rows?.map((row) => [row.id, row]) ?? []), [rows]); - const getDocById = useCallback((id: string) => docMap.get(id), [docMap]); + const docMap = useMemo( + () => + new Map( + rows?.map((row, docIndex) => [row.id, { doc: row, docIndex }]) ?? [] + ), + [rows] + ); + const getDocById = useCallback((id: string) => docMap.get(id)?.doc, [docMap]); const selectedDocsState = useSelectedDocs(docMap); const { isDocSelected, diff --git a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx index b6b8ab20cc577..c101d8c20f751 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_document_selection.tsx @@ -38,7 +38,7 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { const { record, rowIndex } = useControlColumn(props); const { euiTheme } = useEuiTheme(); const { selectedDocsState } = useContext(UnifiedDataTableContext); - const { isDocSelected, toggleDocSelection } = selectedDocsState; + const { isDocSelected, toggleDocSelection, toggleMultipleDocsSelection } = selectedDocsState; const toggleDocumentSelectionLabel = i18n.translate('unifiedDataTable.grid.selectDoc', { defaultMessage: `Select document ''{rowNumber}''`, @@ -66,8 +66,12 @@ export const SelectButton = (props: EuiDataGridCellValueElementProps) => { aria-label={toggleDocumentSelectionLabel} checked={isDocSelected(record.id)} data-test-subj={`dscGridSelectDoc-${record.id}`} - onChange={() => { - toggleDocSelection(record.id); + onChange={(event) => { + if ((event.nativeEvent as MouseEvent)?.shiftKey) { + toggleMultipleDocsSelection(record.id); + } else { + toggleDocSelection(record.id); + } }} /> diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts index 743a3006d25d9..3865e412bdcd2 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.test.ts @@ -17,7 +17,7 @@ describe('useSelectedDocs', () => { const docs = generateEsHits(dataViewWithTimefieldMock, 5).map((hit) => buildDataTableRecord(hit, dataViewWithTimefieldMock) ); - const docsMap = new Map(docs.map((doc) => [doc.id, doc])); + const docsMap = new Map(docs.map((doc, docIndex) => [doc.id, { doc, docIndex }])); test('should have a correct default state', () => { const { result } = renderHook(() => useSelectedDocs(docsMap)); @@ -223,4 +223,30 @@ describe('useSelectedDocs', () => { expect(result.current.getCountOfFilteredSelectedDocs([docs[0].id])).toBe(0); expect(result.current.getCountOfFilteredSelectedDocs([docs[2].id, docs[3].id])).toBe(0); }); + + test('should toggleMultipleDocsSelection correctly', () => { + const { result } = renderHook(() => useSelectedDocs(docsMap)); + const docIds = docs.map((doc) => doc.id); + + // select `0` + act(() => { + result.current.toggleDocSelection(docs[0].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(1); + + // select from `0` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[4].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(5); + + // deselect from `2` to `4` + act(() => { + result.current.toggleMultipleDocsSelection(docs[2].id); + }); + + expect(result.current.getCountOfFilteredSelectedDocs(docIds)).toBe(2); + }); }); diff --git a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts index fd839c88c6363..f6538f185b32f 100644 --- a/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts +++ b/packages/kbn-unified-data-table/src/hooks/use_selected_docs.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import type { DataTableRecord } from '@kbn/discover-utils'; export interface UseSelectedDocsState { @@ -17,6 +17,7 @@ export interface UseSelectedDocsState { selectedDocsCount: number; docIdsInSelectionOrder: string[]; toggleDocSelection: (docId: string) => void; + toggleMultipleDocsSelection: (toDocId: string) => void; selectAllDocs: () => void; selectMoreDocs: (docIds: string[]) => void; deselectSomeDocs: (docIds: string[]) => void; @@ -25,8 +26,11 @@ export interface UseSelectedDocsState { getSelectedDocsOrderedByRows: (rows: DataTableRecord[]) => DataTableRecord[]; } -export const useSelectedDocs = (docMap: Map): UseSelectedDocsState => { +export const useSelectedDocs = ( + docMap: Map +): UseSelectedDocsState => { const [selectedDocsSet, setSelectedDocsSet] = useState>(new Set()); + const lastCheckboxToggledDocId = useRef(); const toggleDocSelection = useCallback((docId: string) => { setSelectedDocsSet((prevSelectedRowsSet) => { @@ -38,6 +42,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect } return newSelectedRowsSet; }); + lastCheckboxToggledDocId.current = docId; }, []); const replaceSelectedDocs = useCallback((docIds: string[]) => { @@ -73,6 +78,42 @@ export const useSelectedDocs = (docMap: Map): UseSelect [selectedDocsSet, docMap] ); + const toggleMultipleDocsSelection = useCallback( + (toDocId: string) => { + const shouldSelect = !isDocSelected(toDocId); + + const lastToggledDocIdIndex = docMap.get( + lastCheckboxToggledDocId.current ?? toDocId + )?.docIndex; + const currentToggledDocIdIndex = docMap.get(toDocId)?.docIndex; + const docIds: string[] = []; + + if ( + typeof lastToggledDocIdIndex === 'number' && + typeof currentToggledDocIdIndex === 'number' && + lastToggledDocIdIndex !== currentToggledDocIdIndex + ) { + const startIndex = Math.min(lastToggledDocIdIndex, currentToggledDocIdIndex); + const endIndex = Math.max(lastToggledDocIdIndex, currentToggledDocIdIndex); + + docMap.forEach(({ doc, docIndex }) => { + if (docIndex >= startIndex && docIndex <= endIndex) { + docIds.push(doc.id); + } + }); + } + + if (shouldSelect) { + selectMoreDocs(docIds); + } else { + deselectSomeDocs(docIds); + } + + lastCheckboxToggledDocId.current = toDocId; + }, + [selectMoreDocs, deselectSomeDocs, docMap, isDocSelected] + ); + const getSelectedDocsOrderedByRows = useCallback( (rows: DataTableRecord[]) => { return rows.filter((row) => isDocSelected(row.id)); @@ -101,6 +142,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect docIdsInSelectionOrder: selectedDocIds, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, @@ -112,6 +154,7 @@ export const useSelectedDocs = (docMap: Map): UseSelect isDocSelected, getCountOfFilteredSelectedDocs, toggleDocSelection, + toggleMultipleDocsSelection, selectAllDocs, selectMoreDocs, deselectSomeDocs, diff --git a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts index 97e56a1de868c..66d3f1323650e 100644 --- a/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts +++ b/test/functional/apps/discover/group2_data_grid3/_data_grid_row_selection.ts @@ -84,6 +84,60 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + it('should be able to select multiple rows holding Shift key', async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); + + // select 1 row + await dataGrid.selectRow(1); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(1); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(1); + }); + + // select 3 more + await dataGrid.selectRow(4, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // deselect index 3 and 4 + await dataGrid.selectRow(3, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(2); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(2); + }); + + // select from index 3 to 0 + await dataGrid.selectRow(0, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(4); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(4); + }); + + // select from both pages + await testSubjects.click('pagination-button-1'); + await retry.try(async () => { + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(0); + }); + + await dataGrid.selectRow(2, { pressShiftKey: true }); + + await retry.try(async () => { + expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(true); + expect(await dataGrid.getNumberOfSelectedRowsOnCurrentPage()).to.be(3); + expect(await dataGrid.getNumberOfSelectedRows()).to.be(8); + }); + }); + it('should be able to bulk select rows', async () => { expect(await dataGrid.isSelectedRowsMenuVisible()).to.be(false); expect(await testSubjects.getAttribute('selectAllDocsOnPageToggle', 'title')).to.be( diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index c5c8db4a9886b..efdaeb49933f2 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -700,12 +700,21 @@ export class DataGridService extends FtrService { await this.checkCurrentRowsPerPageToBe(newValue); } - public async selectRow(rowIndex: number) { + public async selectRow(rowIndex: number, { pressShiftKey }: { pressShiftKey?: boolean } = {}) { const checkbox = await this.find.byCssSelector( `.euiDataGridRow[data-grid-visible-row-index="${rowIndex}"] [data-gridcell-column-id="select"] .euiCheckbox__input` ); - await checkbox.click(); + if (pressShiftKey) { + await this.browser + .getActions() + .keyDown(Key.SHIFT) + .click(checkbox._webElement) + .keyUp(Key.SHIFT) + .perform(); + } else { + await checkbox.click(); + } } public async getNumberOfSelectedRows() { From 249298166fe1499b4aea501056a2291711427c5b Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 25 Sep 2024 12:14:34 +0200 Subject: [PATCH 2/8] [ES|QL] Disable the filter actions for multivalue fields (#193415) ## Summary Part of https://github.com/elastic/kibana/issues/193015 It not allows the creation of where clause filters in case of multi value fields as this is not supported yet in ES|QL. Check my comment here https://github.com/elastic/kibana/issues/193015#issuecomment-2360704651 It might be possible with full text search but I need to talk to the team first. For now we disable it as it creates a wrong filter. ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Davis McPhee --- .../src/utils/append_to_query.test.ts | 6 ++ .../src/utils/append_to_query.ts | 6 +- .../src/components/data_table_columns.tsx | 8 +- .../components/default_cell_actions.test.tsx | 83 +++++++++++++------ .../src/components/default_cell_actions.tsx | 66 ++++++++++----- .../components/layout/discover_layout.tsx | 3 + 6 files changed, 126 insertions(+), 46 deletions(-) diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts index c9db52076222f..9dc15454cbbdf 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.test.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.test.ts @@ -168,5 +168,11 @@ AND \`dest\`=="Crete"` and \`ip\`::string!="127.0.0.2/32"` ); }); + + it('returns undefined for multivalue fields', () => { + expect( + appendWhereClauseToESQLQuery('from logstash-*', 'dest', ['meow'], '+', 'string') + ).toBeUndefined(); + }); }); }); diff --git a/packages/kbn-esql-utils/src/utils/append_to_query.ts b/packages/kbn-esql-utils/src/utils/append_to_query.ts index f4161be073a8d..2820881810387 100644 --- a/packages/kbn-esql-utils/src/utils/append_to_query.ts +++ b/packages/kbn-esql-utils/src/utils/append_to_query.ts @@ -21,7 +21,11 @@ export function appendWhereClauseToESQLQuery( value: unknown, operation: '+' | '-' | 'is_not_null' | 'is_null', fieldType?: string -): string { +): string | undefined { + // multivalues filtering is not supported yet + if (Array.isArray(value)) { + return undefined; + } let operator; switch (operation) { case 'is_not_null': diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx index c5bba429ed934..876db4b0e7149 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx @@ -189,7 +189,13 @@ function buildEuiGridColumn({ cellActions = columnCellActions; } else { cellActions = dataViewField - ? buildCellActions(dataViewField, toastNotifications, valueToStringConverter, onFilter) + ? buildCellActions( + dataViewField, + isPlainRecord, + toastNotifications, + valueToStringConverter, + onFilter + ) : []; if (columnCellActions?.length && cellActionsHandling === 'append') { diff --git a/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx b/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx index 628d3b5a29697..d097a2becd035 100644 --- a/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx +++ b/packages/kbn-unified-data-table/src/components/default_cell_actions.test.tsx @@ -45,6 +45,7 @@ describe('Default cell actions ', function () { it('should not show cell actions for unfilterable fields', async () => { const cellActions = buildCellActions( { name: 'foo', filterable: false } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter ); @@ -61,6 +62,7 @@ describe('Default cell actions ', function () { it('should show filter actions for filterable fields', async () => { const cellActions = buildCellActions( { name: 'foo', filterable: true } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter, jest.fn() @@ -71,6 +73,7 @@ describe('Default cell actions ', function () { it('should show Copy action for _source field', async () => { const cellActions = buildCellActions( { name: '_source', type: '_source', filterable: false } as DataViewField, + false, servicesMock.toastNotifications, dataTableContextMock.valueToStringConverter ); @@ -87,65 +90,97 @@ describe('Default cell actions ', function () { const component = mountWithIntl( } - rowIndex={1} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 1, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + 'jpg', + '+' + ); }); it('triggers filter function when FilterInBtn is clicked for a non-provided value', async () => { const component = mountWithIntl( } - rowIndex={0} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 0, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, undefined, '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + undefined, + '+' + ); }); it('triggers filter function when FilterInBtn is clicked for an empty string value', async () => { const component = mountWithIntl( } - rowIndex={4} - colIndex={1} - columnId="message" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 4, + colIndex: 1, + columnId: 'message', + isExpanded: false, + }} + field={{ name: 'message', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterForButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, '', '+'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'message', filterable: true }, + '', + '+' + ); }); it('triggers filter function when FilterOutBtn is clicked', async () => { const component = mountWithIntl( } - rowIndex={1} - colIndex={1} - columnId="extension" - isExpanded={false} + cellActionProps={{ + Component: (props: any) => , + rowIndex: 1, + colIndex: 1, + columnId: 'extension', + isExpanded: false, + }} + field={{ name: 'extension', filterable: true } as DataViewField} + isPlainRecord={false} /> ); const button = findTestSubject(component, 'filterOutButton'); await button.simulate('click'); - expect(dataTableContextMock.onFilter).toHaveBeenCalledWith({}, 'jpg', '-'); + expect(dataTableContextMock.onFilter).toHaveBeenCalledWith( + { name: 'extension', filterable: true }, + 'jpg', + '-' + ); }); it('triggers clipboard copy when CopyBtn is clicked', async () => { const component = mountWithIntl( diff --git a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx index 0be9803633825..51730aef44349 100644 --- a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx +++ b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx @@ -32,11 +32,25 @@ function onFilterCell( } } -export const FilterInBtn = ( - { Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps, - field: DataViewField -) => { +const esqlMultivalueFilteringDisabled = i18n.translate( + 'unifiedDataTable.grid.esqlMultivalueFilteringDisabled', + { + defaultMessage: 'Multivalue filtering is not supported in ES|QL', + } +); + +export const FilterInBtn = ({ + cellActionProps: { Component, rowIndex, columnId }, + field, + isPlainRecord, +}: { + cellActionProps: EuiDataGridColumnCellActionProps; + field: DataViewField; + isPlainRecord: boolean | undefined; +}) => { const context = useContext(UnifiedDataTableContext); + const filteringDisabled = + isPlainRecord && Array.isArray(context.rows[rowIndex]?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterForAria', { defaultMessage: 'Filter for this {value}', values: { value: columnId }, @@ -49,7 +63,8 @@ export const FilterInBtn = ( }} iconType="plusInCircle" aria-label={buttonTitle} - title={buttonTitle} + title={filteringDisabled ? esqlMultivalueFilteringDisabled : buttonTitle} + disabled={filteringDisabled} data-test-subj="filterForButton" > {i18n.translate('unifiedDataTable.grid.filterFor', { @@ -59,11 +74,18 @@ export const FilterInBtn = ( ); }; -export const FilterOutBtn = ( - { Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps, - field: DataViewField -) => { +export const FilterOutBtn = ({ + cellActionProps: { Component, rowIndex, columnId }, + field, + isPlainRecord, +}: { + cellActionProps: EuiDataGridColumnCellActionProps; + field: DataViewField; + isPlainRecord: boolean | undefined; +}) => { const context = useContext(UnifiedDataTableContext); + const filteringDisabled = + isPlainRecord && Array.isArray(context.rows[rowIndex]?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterOutAria', { defaultMessage: 'Filter out this {value}', values: { value: columnId }, @@ -76,7 +98,8 @@ export const FilterOutBtn = ( }} iconType="minusInCircle" aria-label={buttonTitle} - title={buttonTitle} + title={filteringDisabled ? esqlMultivalueFilteringDisabled : buttonTitle} + disabled={filteringDisabled} data-test-subj="filterOutButton" > {i18n.translate('unifiedDataTable.grid.filterOut', { @@ -120,6 +143,7 @@ export function buildCopyValueButton( export function buildCellActions( field: DataViewField, + isPlainRecord: boolean | undefined, toastNotifications: ToastsStart, valueToStringConverter: ValueToStringConverter, onFilter?: DocViewFilterFn @@ -127,16 +151,18 @@ export function buildCellActions( return [ ...(onFilter && field.filterable ? [ - ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => - FilterInBtn( - { Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps, - field - ), - ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => - FilterOutBtn( - { Component, rowIndex, columnId } as EuiDataGridColumnCellActionProps, - field - ), + (cellActionProps: EuiDataGridColumnCellActionProps) => + FilterInBtn({ + cellActionProps, + field, + isPlainRecord, + }), + (cellActionProps: EuiDataGridColumnCellActionProps) => + FilterOutBtn({ + cellActionProps, + field, + isPlainRecord, + }), ] : []), ({ Component, rowIndex, columnId }: EuiDataGridColumnCellActionProps) => diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index 82403ad38c710..49e645e3f2206 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -213,6 +213,9 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) { getOperator(fieldName, values, operation), fieldType ); + if (!updatedQuery) { + return; + } data.query.queryString.setQuery({ esql: updatedQuery, }); From b8398c7b684d9fb4745c0d9d8c5a9c60a5eddc48 Mon Sep 17 00:00:00 2001 From: Jusheng Huang <117657272+viajes7@users.noreply.github.com> Date: Wed, 25 Sep 2024 18:36:25 +0800 Subject: [PATCH 3/8] [Advanced Settings] Fix search does not work for terms with ":" (#193506) ## Summary Fixes #192694 When terms with `:`, it will be transformed to a `FieldClause`. So, I'm try to set it back to a `TermClause` if the field is not in `FieldDefinition`. Example: `-defaultValue:false doc_table:high Dashboard` ![image](https://github.com/user-attachments/assets/e9519cfe-c64d-4bd4-b928-d6ffd9bfaa8b) --------- Co-authored-by: Elastic Machine Co-authored-by: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com> --- .../settings/application/hooks/use_fields.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/kbn-management/settings/application/hooks/use_fields.ts b/packages/kbn-management/settings/application/hooks/use_fields.ts index 829e368812512..f0dc538f54189 100644 --- a/packages/kbn-management/settings/application/hooks/use_fields.ts +++ b/packages/kbn-management/settings/application/hooks/use_fields.ts @@ -7,11 +7,13 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { Query } from '@elastic/eui'; +import { Ast, Query } from '@elastic/eui'; import { getFieldDefinitions } from '@kbn/management-settings-field-definition'; import { FieldDefinition } from '@kbn/management-settings-types'; import { UiSettingsScope } from '@kbn/core-ui-settings-common'; +import { Clause } from '@elastic/eui/src/components/search_bar/query/ast'; import { useServices } from '../services'; +import { CATEGORY_FIELD } from '../query_input'; import { useSettings } from './use_settings'; /** @@ -29,7 +31,19 @@ export const useFields = (scope: UiSettingsScope, query?: Query): FieldDefinitio isOverridden: (key) => isOverriddenSetting(key, scope), }); if (query) { - return Query.execute(query, fields); + const clauses: Clause[] = query.ast.clauses.map((clause) => + // If the clause value contains `:` and is not a category filter, add it as a term clause + // This allows searching for settings that include `:` in their names + clause.type === 'field' && clause.field !== CATEGORY_FIELD + ? { + type: 'term', + match: 'must', + value: `${clause.field}:${clause.value}`, + } + : clause + ); + + return Query.execute(new Query(Ast.create(clauses), undefined, query.text), fields); } return fields; }; From e92c905768c93c13977c62a44e8ee715597b4633 Mon Sep 17 00:00:00 2001 From: Katerina Date: Wed, 25 Sep 2024 13:54:11 +0300 Subject: [PATCH 4/8] [Inventory][ECO] Add open in discover button (#193891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary closes https://github.com/elastic/kibana/issues/192327 - Filters by default the entity definition that are supported in the table ### Checklist https://github.com/user-attachments/assets/577c2816-8ba6-4d35-b020-c3114e86b2ee --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Cauê Marcondes <55978943+cauemarcondes@users.noreply.github.com> --- .../inventory/common/entities.ts | 12 +++ .../public/components/entities_grid/index.tsx | 5 +- .../components/search_bar/discover_button.tsx | 83 +++++++++++++++++++ .../public/components/search_bar/index.tsx | 38 ++++++--- .../server/routes/entities/query_helper.ts | 14 +--- .../inventory/tsconfig.json | 1 + 6 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index cc69a4c4a687e..2135688d75467 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -23,6 +23,18 @@ export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ dataset: ENTITY_LATEST, }); +const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; +const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; +const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; + +export const defaultEntityDefinitions = [ + BUILTIN_SERVICES_FROM_ECS_DATA, + BUILTIN_HOSTS_FROM_ECS_DATA, + BUILTIN_CONTAINERS_FROM_ECS_DATA, +]; + +export const defaultEntityTypes: EntityType[] = ['service', 'host', 'container']; + const entityArrayRt = t.array(entityTypeRt); export const entityTypesRt = new t.Type( 'entityTypesRt', diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index d5ab911605109..e24335b165359 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -31,7 +31,10 @@ import { EntityType } from '../../../common/entities'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; -type EntityColumnIds = typeof ENTITY_DISPLAY_NAME | typeof ENTITY_LAST_SEEN | typeof ENTITY_TYPE; +export type EntityColumnIds = + | typeof ENTITY_DISPLAY_NAME + | typeof ENTITY_LAST_SEEN + | typeof ENTITY_TYPE; const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( <> diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx new file mode 100644 index 0000000000000..d91ca5bf7d2d9 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { buildPhrasesFilter, PhrasesFilter } from '@kbn/es-query'; + +import { useKibana } from '../../hooks/use_kibana'; +import { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '../../../common/es_fields/entities'; +import { EntityColumnIds } from '../entities_grid'; +import { defaultEntityDefinitions } from '../../../common/entities'; +import { useInventoryParams } from '../../hooks/use_inventory_params'; + +const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; + +export function DiscoverButton({ dataView }: { dataView: DataView }) { + const { + services: { share, application }, + } = useKibana(); + const { + query: { kuery, entityTypes }, + } = useInventoryParams('/*'); + + const discoverLocator = useMemo( + () => share.url.locators.get('DISCOVER_APP_LOCATOR'), + [share.url.locators] + ); + + const filters: PhrasesFilter[] = []; + + const entityDefinitionField = dataView.getFieldByName(ENTITY_DEFINITION_ID); + + if (entityDefinitionField) { + const entityDefinitionFilter = buildPhrasesFilter( + entityDefinitionField!, + defaultEntityDefinitions, + dataView + ); + filters.push(entityDefinitionFilter); + } + + const entityTypeField = dataView.getFieldByName(ENTITY_TYPE); + + if (entityTypes && entityTypeField) { + const entityTypeFilter = buildPhrasesFilter(entityTypeField, entityTypes, dataView); + filters.push(entityTypeFilter); + } + + const discoverLink = discoverLocator?.getRedirectUrl({ + indexPatternId: dataView?.id ?? '', + columns: ACTIVE_COLUMNS, + query: { query: kuery ?? '', language: 'kuery' }, + filters, + }); + + if (!application.capabilities.discover?.show || !discoverLink) { + return null; + } + + return ( + + {i18n.translate('xpack.inventory.searchBar.discoverButton', { + defaultMessage: 'Open in discover', + })} + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx index 0b3beb5e00f8c..46ef45cfc195d 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/index.tsx @@ -8,12 +8,14 @@ import { i18n } from '@kbn/i18n'; import { SearchBarOwnProps } from '@kbn/unified-search-plugin/public/search_bar'; import deepEqual from 'fast-deep-equal'; import React, { useCallback, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EntityType } from '../../../common/entities'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useAdHocInventoryDataView } from '../../hooks/use_adhoc_inventory_data_view'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useKibana } from '../../hooks/use_kibana'; import { EntityTypesControls } from './entity_types_controls'; +import { DiscoverButton } from './discover_button'; export function SearchBar() { const { searchBarContentSubject$ } = useInventorySearchBarContext(); @@ -68,18 +70,28 @@ export function SearchBar() { ); return ( - } - onQuerySubmit={handleQuerySubmit} - placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { - defaultMessage: - 'Search for your entities by name or its metadata (e.g. entity.type : service)', - })} - /> + + + } + onQuerySubmit={handleQuerySubmit} + placeholder={i18n.translate('xpack.inventory.searchBar.placeholder', { + defaultMessage: + 'Search for your entities by name or its metadata (e.g. entity.type : service)', + })} + /> + + + {dataView ? ( + + + + ) : null} + ); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts index c27e5ffd103aa..cb0257010f3c0 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/query_helper.ts @@ -5,23 +5,13 @@ * 2.0. */ -import { EntityType } from '../../../common/entities'; +import { EntityType, defaultEntityTypes, defaultEntityDefinitions } from '../../../common/entities'; import { ENTITY_DEFINITION_ID, ENTITY_TYPE } from '../../../common/es_fields/entities'; -const defaultEntityTypes: EntityType[] = ['service', 'host', 'container']; - export const getEntityTypesWhereClause = (entityTypes: EntityType[] = defaultEntityTypes) => `WHERE ${ENTITY_TYPE} IN (${entityTypes.map((entityType) => `"${entityType}"`).join()})`; -const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data'; -const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data'; -const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data'; - export const getEntityDefinitionIdWhereClause = () => - `WHERE ${ENTITY_DEFINITION_ID} IN (${[ - BUILTIN_SERVICES_FROM_ECS_DATA, - BUILTIN_HOSTS_FROM_ECS_DATA, - BUILTIN_CONTAINERS_FROM_ECS_DATA, - ] + `WHERE ${ENTITY_DEFINITION_ID} IN (${[...defaultEntityDefinitions] .map((buildin) => `"${buildin}"`) .join()})`; diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index a391737762c52..c4b6b55d41f4c 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -40,6 +40,7 @@ "@kbn/data-plugin", "@kbn/core-analytics-browser", "@kbn/core-http-browser", + "@kbn/es-query", "@kbn/shared-svg" ] } From 8d7dad266d8515509e5c7aa5029680f76e7cc348 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Wed, 25 Sep 2024 17:57:48 +0700 Subject: [PATCH 5/8] [Logs Data Telemetry] Add logs-dsns `logs-*-*` pattern to obs telemetry patterns (#192874) ## Summary Adds the `logs-*-*` logs pattern in observability telemetry patterns to separate out logs with indices named per the Data Stream Naming Scheme ([ref](https://www.elastic.co/blog/an-introduction-to-the-elastic-data-stream-naming-scheme)) vs. the generic logs indices not conforming to DSNS naming. For a typical scenario (test data), the addition of `logs-*-*` affects the collection as (notice the addition of "dsns-logs" object):
Before After
```yml [ { "pattern_name": "heartbeat", "shipper": "heartbeat", "doc_count": 9530, "structure_level": { "5": 9530 }, "index_count": 1, "failure_store_doc_count": 9530, "failure_store_index_count": 1, "namespace_count": 0, "field_count": 1508, "field_existence": { "container.id": 9530, "log.level": 9530, "container.name": 9530, "host.name": 9530, "host.hostname": 9530, "kubernetes.pod.name": 9530, "kubernetes.pod.uid": 9530, "cloud.provider": 9530, "agent.type": 9530, "event.dataset": 9530, "event.category": 9530, "event.module": 9530, "service.name": 9530, "service.type": 9530, "service.version": 9530, "message": 9530, "event.original": 9530, "error.message": 9530, "@timestamp": 9530, "data_stream.dataset": 9530, "data_stream.namespace": 9530, "data_stream.type": 9530 }, "size_in_bytes": 13090458, "managed_by": [], "package_name": [], "beat": [ "heartbeat" ] }, { "pattern_name": "nginx", "doc_count": 10080, "structure_level": { "6": 10080 }, "index_count": 1, "failure_store_doc_count": 0, "failure_store_index_count": 0, "namespace_count": 1, "field_count": 1562, "field_existence": { "container.id": 10080, "log.level": 10080, "host.name": 10080, "kubernetes.pod.uid": 10080, "cloud.provider": 10080, "event.dataset": 10080, "service.name": 10080, "message": 10080, "@timestamp": 10080, "data_stream.dataset": 10080, "data_stream.namespace": 10080, "data_stream.type": 10080 }, "size_in_bytes": 12098071, "managed_by": [], "package_name": [], "beat": [] }, { "pattern_name": "apache", "doc_count": 1643, "structure_level": { "6": 1643 }, "index_count": 2, "failure_store_doc_count": 0, "failure_store_index_count": 0, "namespace_count": 2, "field_count": 1562, "field_existence": { "container.id": 1643, "log.level": 1643, "host.name": 1643, "kubernetes.pod.uid": 1643, "cloud.provider": 1643, "event.dataset": 1643, "service.name": 1643, "message": 1643, "@timestamp": 1643, "data_stream.dataset": 1643, "data_stream.namespace": 1643, "data_stream.type": 1643 }, "size_in_bytes": 5593675, "managed_by": [], "package_name": [], "beat": [] }, { "pattern_name": "generic-logs", "doc_count": 123979, "structure_level": { "2": 112925, "3": 11054 }, "index_count": 18, "failure_store_doc_count": 2, "failure_store_index_count": 1, "namespace_count": 3, "field_count": 1582, "field_existence": { "container.id": 11054, "log.level": 123979, "host.name": 123979, "kubernetes.pod.uid": 11046, "cloud.provider": 11046, "event.dataset": 11046, "service.name": 123971, "message": 11054, "@timestamp": 123979, "data_stream.dataset": 123979, "data_stream.namespace": 123979, "data_stream.type": 123979 }, "size_in_bytes": 60270084, "managed_by": [], "package_name": [], "beat": [] } ] ``` ```yml [ { "pattern_name": "heartbeat", "shipper": "heartbeat", "doc_count": 9530, "structure_level": { "5": 9530 }, "index_count": 1, "failure_store_doc_count": 9530, "failure_store_index_count": 1, "namespace_count": 0, "field_count": 1508, "field_existence": { "container.id": 9530, "log.level": 9530, "container.name": 9530, "host.name": 9530, "host.hostname": 9530, "kubernetes.pod.name": 9530, "kubernetes.pod.uid": 9530, "cloud.provider": 9530, "agent.type": 9530, "event.dataset": 9530, "event.category": 9530, "event.module": 9530, "service.name": 9530, "service.type": 9530, "service.version": 9530, "message": 9530, "event.original": 9530, "error.message": 9530, "@timestamp": 9530, "data_stream.dataset": 9530, "data_stream.namespace": 9530, "data_stream.type": 9530 }, "size_in_bytes": 13090458, "managed_by": [], "package_name": [], "beat": [ "heartbeat" ] }, { "pattern_name": "nginx", "doc_count": 10080, "structure_level": { "6": 10080 }, "index_count": 1, "failure_store_doc_count": 0, "failure_store_index_count": 0, "namespace_count": 1, "field_count": 1562, "field_existence": { "container.id": 10080, "log.level": 10080, "host.name": 10080, "kubernetes.pod.uid": 10080, "cloud.provider": 10080, "event.dataset": 10080, "service.name": 10080, "message": 10080, "@timestamp": 10080, "data_stream.dataset": 10080, "data_stream.namespace": 10080, "data_stream.type": 10080 }, "size_in_bytes": 12098071, "managed_by": [], "package_name": [], "beat": [] }, { "pattern_name": "apache", "doc_count": 1643, "structure_level": { "6": 1643 }, "index_count": 2, "failure_store_doc_count": 0, "failure_store_index_count": 0, "namespace_count": 2, "field_count": 1562, "field_existence": { "container.id": 1643, "log.level": 1643, "host.name": 1643, "kubernetes.pod.uid": 1643, "cloud.provider": 1643, "event.dataset": 1643, "service.name": 1643, "message": 1643, "@timestamp": 1643, "data_stream.dataset": 1643, "data_stream.namespace": 1643, "data_stream.type": 1643 }, "size_in_bytes": 5593675, "managed_by": [], "package_name": [], "beat": [] }, { "pattern_name": "dsns-logs", "doc_count": 123971, "structure_level": { "2": 112925, "6": 11046 }, "index_count": 17, "failure_store_doc_count": 0, "failure_store_index_count": 0, "namespace_count": 2, "field_count": 1581, "field_existence": { "container.id": 11046, "log.level": 123971, "host.name": 123971, "kubernetes.pod.uid": 11046, "cloud.provider": 11046, "event.dataset": 11046, "service.name": 123971, "message": 11046, "@timestamp": 123971, "data_stream.dataset": 123971, "data_stream.namespace": 123971, "data_stream.type": 123971 }, "size_in_bytes": 60245641, "managed_by": [], "package_name": [], "beat": [] }, { "pattern_name": "generic-logs", "doc_count": 8, "structure_level": { "3": 8 }, "index_count": 1, "failure_store_doc_count": 2, "failure_store_index_count": 1, "namespace_count": 3, "field_count": 1582, "field_existence": { "container.id": 8, "log.level": 8, "host.name": 8, "message": 8, "@timestamp": 8, "data_stream.dataset": 8, "data_stream.namespace": 8, "data_stream.type": 8 }, "size_in_bytes": 24826, "managed_by": [], "package_name": [], "beat": [] } ] ```
Co-authored-by: Elastic Machine --- .../telemetry_collection/get_data_telemetry/constants.ts | 1 + .../server/services/data_telemetry/constants.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts index 60bd01c2bd78c..24e236a9858a3 100644 --- a/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts +++ b/src/plugins/telemetry/server/telemetry_collection/get_data_telemetry/constants.ts @@ -58,6 +58,7 @@ export const DATA_DATASETS_INDEX_PATTERNS = [ { pattern: 'fluent-bit*', patternName: 'fluentbit' }, { pattern: '*nginx*', patternName: 'nginx' }, { pattern: '*apache*', patternName: 'apache' }, // Already in Security (keeping it in here for documentation) + { pattern: 'logs-*-*', patternName: 'dsns-logs' }, { pattern: '*logs*', patternName: 'generic-logs' }, // Security - Elastic diff --git a/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts b/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts index 619a6efc3bfdd..7f03b4d67ce5c 100644 --- a/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts +++ b/x-pack/plugins/observability_solution/dataset_quality/server/services/data_telemetry/constants.ts @@ -25,7 +25,8 @@ export const EXCLUDE_ELASTIC_LOGS = ['logs-synth', 'logs-elastic', 'logs-endpoin export const TELEMETRY_CHANNEL = 'logs-data-telemetry'; -const LOGS_INDEX_PATTERN_NAMES = [ +type ObsPatternName = (typeof DATA_DATASETS_INDEX_PATTERNS_UNIQUE)[number]['patternName']; +const LOGS_INDEX_PATTERN_NAMES: ObsPatternName[] = [ 'filebeat', 'generic-filebeat', 'metricbeat', @@ -43,6 +44,7 @@ const LOGS_INDEX_PATTERN_NAMES = [ 'fluentbit', 'nginx', 'apache', + 'dsns-logs', 'generic-logs', ]; From 22f451b30df3f80974b73314c9db4c8f8a509789 Mon Sep 17 00:00:00 2001 From: Abhishek Bhatia <117628830+abhishekbhatia1710@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:05:24 +0530 Subject: [PATCH 6/8] [Entity Analytics] New API endpoint to cleanup the risk engine installation and data (#191843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 1. Create a new public API endpoint : `DELETE /api/risk_score/engine/dangerously_delete_data` ## Test cases Result ``` PASS x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts (7.017 s) risk engine cleanup route invokes the risk engine cleanup route ✓ should call the router with the correct route and handler (71 ms) ✓ returns a 200 when cleanup is successful (64 ms) ✓ returns a 500 when cleanup is unsuccessful (57 ms) ✓ returns a 500 when cleanup is unsuccessful with multiple errors (53 ms) when task manager is unavailable ✓ returns a 400 when task manager is unavailable (55 ms) when user does not have the required privileges ✓ returns a 403 when user does not have the required privileges (88 ms) ``` ### API Responses ## When multiple errors encountered ``` { "risk_engine_cleanup": false, "errors": [ { "seq": 1, "error": "resource_not_found_exception\n\tRoot causes:\n\t\tresource_not_found_exception: Transform with id [risk_score_latest_transform_default] could not be found" }, { "seq": 2, "error": "index_not_found_exception\n\tRoot causes:\n\t\tindex_not_found_exception: no such index [risk-score.risk-score-default]" }, { "seq": 3, "error": "index_template_missing_exception\n\tRoot causes:\n\t\tindex_template_missing_exception: index_template [.risk-score.risk-score-default-index-template] missing" }, { "seq": 4, "error": "resource_not_found_exception\n\tRoot causes:\n\t\tresource_not_found_exception: .risk-score-mappings" } ], "status_code": 500 } ``` ## Success ``` { "risk_engine_cleanup": true } ``` ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### OpenAPI spec ![image](https://github.com/user-attachments/assets/56d69602-061d-4a01-9d2b-01a8398ffc76) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../output/kibana.serverless.staging.yaml | 54 +++++ oas_docs/output/kibana.staging.yaml | 54 +++++ .../risk_engine/engine_cleanup_route.gen.ts | 33 ++++ .../engine_cleanup_route.schema.yaml | 60 ++++++ .../api/entity_analytics/risk_engine/index.ts | 1 + .../common/api/quickstart_client.gen.ts | 16 ++ .../entity_analytics/risk_engine/constants.ts | 1 + ...entity_analytics_api_1.bundled.schema.yaml | 88 +++++++++ ...entity_analytics_api_1.bundled.schema.yaml | 88 +++++++++ .../public/entity_analytics/api/api.ts | 30 ++- .../risk_engine_data_client.mock.ts | 5 +- .../risk_engine/routes/delete.test.ts | 186 ++++++++++++++++++ .../risk_engine/routes/delete.ts | 103 ++++++++++ .../routes/register_risk_engine_routes.ts | 2 + .../routes/risk_engine_privileges.mock.ts | 21 ++ .../services/security_solution_api.gen.ts | 10 + .../trial_license_complete_tier/index.ts | 1 + .../risk_engine_cleanup_api.ts | 67 +++++++ .../entity_analytics/utils/risk_engine.ts | 9 + 19 files changed, 823 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts create mode 100644 x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml create mode 100644 x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml create mode 100644 x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 46b4191056f35..58abfc3e1f7f9 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -15392,6 +15392,39 @@ paths: tags: - Security Timeline API - access:securitySolution + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_TaskManagerUnavailableResponse + description: Task manager is unavailable + default: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API /api/risk_score/engine/schedule_now: post: description: >- @@ -29750,6 +29783,27 @@ components: required: - id_value - id_field + Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors Security_Entity_Analytics_API_CreateAssetCriticalityRecord: allOf: - $ref: >- diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index ea8c34440c3b2..59a22166f3498 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -18822,6 +18822,39 @@ paths: tags: - Security Timeline API - access:securitySolution + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_TaskManagerUnavailableResponse + description: Task manager is unavailable + default: + content: + application/json; Elastic-Api-Version=2023-10-31: + schema: + $ref: >- + #/components/schemas/Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API /api/risk_score/engine/schedule_now: post: description: >- @@ -37759,6 +37792,27 @@ components: required: - id_value - id_field + Security_Entity_Analytics_API_CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors Security_Entity_Analytics_API_CreateAssetCriticalityRecord: allOf: - $ref: >- diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts new file mode 100644 index 0000000000000..13194051244cb --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.gen.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + * + * info: + * title: Risk Scoring API + * version: 1 + */ + +import { z } from '@kbn/zod'; + +export type CleanUpRiskEngineErrorResponse = z.infer; +export const CleanUpRiskEngineErrorResponse = z.object({ + cleanup_successful: z.boolean(), + errors: z.array( + z.object({ + seq: z.number().int(), + error: z.string(), + }) + ), +}); + +export type CleanUpRiskEngineResponse = z.infer; +export const CleanUpRiskEngineResponse = z.object({ + cleanup_successful: z.boolean().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml new file mode 100644 index 0000000000000..2dffe3879961e --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/engine_cleanup_route.schema.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.0 +info: + version: '1' + title: Risk Scoring API + description: These APIs allow the consumer to manage Entity Risk Scores within Entity Analytics. +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + x-labels: [ess, serverless] + x-codegen-enabled: true + operationId: CleanUpRiskEngine + summary: Cleanup the Risk Engine + description: Cleaning up the the Risk Engine by removing the indices, mapping and transforms + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + '400': + description: Task manager is unavailable + content: + application/json: + schema: + $ref: '../common/common.schema.yaml#/components/schemas/TaskManagerUnavailableResponse' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + required: + - cleanup_successful + - errors + properties: + cleanup_successful: + type: boolean + example: false + errors: + type: array + items: + type: object + required: + - seq + - error + properties: + seq: + type: integer + error: + type: string + diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts index 94d587cd2bfc7..21dc89544c8d8 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/index.ts @@ -15,3 +15,4 @@ export * from './calculation_route.gen'; export * from './preview_route.gen'; export * from './entity_calculation_route.gen'; export * from './get_risk_engine_privileges.gen'; +export * from './engine_cleanup_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts index 1af4e60124ef1..9b057bb19d7e2 100644 --- a/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts +++ b/x-pack/plugins/security_solution/common/api/quickstart_client.gen.ts @@ -274,6 +274,7 @@ import type { ListEntitiesRequestQueryInput, ListEntitiesResponse, } from './entity_analytics/entity_store/entities/list_entities.gen'; +import type { CleanUpRiskEngineResponse } from './entity_analytics/risk_engine/engine_cleanup_route.gen'; import type { DisableRiskEngineResponse } from './entity_analytics/risk_engine/engine_disable_route.gen'; import type { EnableRiskEngineResponse } from './entity_analytics/risk_engine/engine_enable_route.gen'; import type { InitRiskEngineResponse } from './entity_analytics/risk_engine/engine_init_route.gen'; @@ -540,6 +541,21 @@ If asset criticality records already exist for the specified entities, those rec }) .catch(catchAxiosErrorFormatAndThrow); } + /** + * Cleaning up the the Risk Engine by removing the indices, mapping and transforms + */ + async cleanUpRiskEngine() { + this.log.info(`${new Date().toISOString()} Calling API CleanUpRiskEngine`); + return this.kbnClient + .request({ + path: '/api/risk_score/engine/dangerously_delete_data', + headers: { + [ELASTIC_HTTP_VERSION_HEADER]: '1', + }, + method: 'DELETE', + }) + .catch(catchAxiosErrorFormatAndThrow); + } async createAlertsIndex() { this.log.info(`${new Date().toISOString()} Calling API CreateAlertsIndex`); return this.kbnClient diff --git a/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts b/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts index 17cfcf1da8e84..0eda694aed24b 100644 --- a/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts +++ b/x-pack/plugins/security_solution/common/entity_analytics/risk_engine/constants.ts @@ -16,6 +16,7 @@ export const RISK_ENGINE_SETTINGS_URL = `${RISK_ENGINE_URL}/settings` as const; // Public Risk Score routes export const PUBLIC_RISK_ENGINE_URL = `${PUBLIC_RISK_SCORE_URL}/engine` as const; export const RISK_ENGINE_SCHEDULE_NOW_URL = `${RISK_ENGINE_URL}/schedule_now` as const; +export const RISK_ENGINE_CLEANUP_URL = `${PUBLIC_RISK_ENGINE_URL}/dangerously_delete_data` as const; type ClusterPrivilege = 'manage_index_templates' | 'manage_transform'; export const RISK_ENGINE_REQUIRED_ES_CLUSTER_PRIVILEGES = [ diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml new file mode 100644 index 0000000000000..9d6d57abd382a --- /dev/null +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_entity_analytics_api_1.bundled.schema.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + description: '' + title: Security Entity Analytics API (Elastic Cloud and self-hosted) + version: '1' +servers: + - url: http://{kibana_host}:{port} + variables: + kibana_host: + default: localhost + port: + default: '5601' +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/TaskManagerUnavailableResponse' + description: Task manager is unavailable + default: + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors + TaskManagerUnavailableResponse: + description: Task manager is unavailable + type: object + properties: + message: + type: string + status_code: + minimum: 400 + type: integer + required: + - status_code + - message + securitySchemes: + BasicAuth: + scheme: basic + type: http +security: + - BasicAuth: [] +tags: + - description: '' + name: Security Entity Analytics API diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml new file mode 100644 index 0000000000000..835d8f79b1fea --- /dev/null +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_entity_analytics_api_1.bundled.schema.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + description: '' + title: Security Entity Analytics API (Elastic Cloud Serverless) + version: '1' +servers: + - url: http://{kibana_host}:{port} + variables: + kibana_host: + default: localhost + port: + default: '5601' +paths: + /api/risk_score/engine/dangerously_delete_data: + delete: + description: >- + Cleaning up the the Risk Engine by removing the indices, mapping and + transforms + operationId: CleanUpRiskEngine + responses: + '200': + content: + application/json: + schema: + type: object + properties: + cleanup_successful: + type: boolean + description: Successful response + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/TaskManagerUnavailableResponse' + description: Task manager is unavailable + default: + content: + application/json: + schema: + $ref: '#/components/schemas/CleanUpRiskEngineErrorResponse' + description: Unexpected error + summary: Cleanup the Risk Engine + tags: + - Security Entity Analytics API +components: + schemas: + CleanUpRiskEngineErrorResponse: + type: object + properties: + cleanup_successful: + example: false + type: boolean + errors: + items: + type: object + properties: + error: + type: string + seq: + type: integer + required: + - seq + - error + type: array + required: + - cleanup_successful + - errors + TaskManagerUnavailableResponse: + description: Task manager is unavailable + type: object + properties: + message: + type: string + status_code: + minimum: 400 + type: integer + required: + - status_code + - message + securitySchemes: + BasicAuth: + scheme: basic + type: http +security: + - BasicAuth: [] +tags: + - description: '' + name: Security Entity Analytics API diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index f958d20d7c96b..18cb9ef570bd5 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -7,12 +7,12 @@ import { useMemo } from 'react'; import { LIST_ENTITIES_URL } from '../../../common/entity_analytics/entity_store/constants'; -import type { RiskEngineScheduleNowResponse } from '../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen'; -import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen'; import type { UploadAssetCriticalityRecordsResponse } from '../../../common/api/entity_analytics/asset_criticality/upload_asset_criticality_csv.gen'; +import type { DisableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_disable_route.gen'; import type { RiskEngineStatusResponse } from '../../../common/api/entity_analytics/risk_engine/engine_status_route.gen'; import type { InitRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_init_route.gen'; import type { EnableRiskEngineResponse } from '../../../common/api/entity_analytics/risk_engine/engine_enable_route.gen'; +import type { RiskEngineScheduleNowResponse } from '../../../common/api/entity_analytics/risk_engine/engine_schedule_now_route.gen'; import type { RiskScoresPreviewRequest, RiskScoresPreviewResponse, @@ -40,6 +40,7 @@ import { ASSET_CRITICALITY_PUBLIC_CSV_UPLOAD_URL, RISK_SCORE_ENTITY_CALCULATION_URL, API_VERSIONS, + RISK_ENGINE_CLEANUP_URL, RISK_ENGINE_SCHEDULE_NOW_URL, } from '../../../common/constants'; import type { SnakeToCamelCase } from '../common/utils'; @@ -191,12 +192,18 @@ export const useEntityAnalyticsRoutes = () => { }); const deleteAssetCriticality = async ( - params: Pick & { refresh?: 'wait_for' } + params: Pick & { + refresh?: 'wait_for'; + } ): Promise<{ deleted: true }> => { await http.fetch(ASSET_CRITICALITY_PUBLIC_URL, { version: API_VERSIONS.public.v1, method: 'DELETE', - query: { id_value: params.idValue, id_field: params.idField, refresh: params.refresh }, + query: { + id_value: params.idValue, + id_field: params.idField, + refresh: params.refresh, + }, }); // spoof a response to allow us to better distnguish a delete from a create in use_asset_criticality.ts @@ -220,7 +227,9 @@ export const useEntityAnalyticsRoutes = () => { fileContent: string, fileName: string ): Promise => { - const file = new File([new Blob([fileContent])], fileName, { type: 'text/csv' }); + const file = new File([new Blob([fileContent])], fileName, { + type: 'text/csv', + }); const body = new FormData(); body.append('file', file); @@ -267,6 +276,16 @@ export const useEntityAnalyticsRoutes = () => { method: 'GET', }); + /** + * Deletes Risk engine installation and associated data + */ + + const cleanUpRiskEngine = () => + http.fetch(RISK_ENGINE_CLEANUP_URL, { + version: '1', + method: 'DELETE', + }); + return { fetchRiskScorePreview, fetchRiskEngineStatus, @@ -283,6 +302,7 @@ export const useEntityAnalyticsRoutes = () => { getRiskScoreIndexStatus, fetchRiskEngineSettings, calculateEntityRiskScore, + cleanUpRiskEngine, fetchEntitiesList, }; }, [http]); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts index a8d7b7a9c763b..e9819c5b290d3 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/risk_engine_data_client.mock.ts @@ -15,6 +15,9 @@ const createRiskEngineDataClientMock = () => getConfiguration: jest.fn(), getStatus: jest.fn(), init: jest.fn(), + tearDown: jest.fn(), } as unknown as jest.Mocked); -export const riskEngineDataClientMock = { create: createRiskEngineDataClientMock }; +export const riskEngineDataClientMock = { + create: createRiskEngineDataClientMock, +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts new file mode 100644 index 0000000000000..5c66b70c75c13 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.test.ts @@ -0,0 +1,186 @@ +/* + * 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 { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { RISK_ENGINE_CLEANUP_URL } from '../../../../../common/constants'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../../detection_engine/routes/__mocks__'; +import { riskEnginePrivilegesMock } from './risk_engine_privileges.mock'; +import { riskEngineDataClientMock } from '../risk_engine_data_client.mock'; +import { riskEngineCleanupRoute } from './delete'; + +describe('risk engine cleanup route', () => { + let server: ReturnType; + let context: ReturnType; + let mockTaskManagerStart: ReturnType; + let mockRiskEngineDataClient: ReturnType; + let getStartServicesMock: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + server = serverMock.create(); + const { clients } = requestContextMock.createTools(); + mockRiskEngineDataClient = riskEngineDataClientMock.create(); + context = requestContextMock.convertContext( + requestContextMock.create({ + ...clients, + riskEngineDataClient: mockRiskEngineDataClient, + }) + ); + mockTaskManagerStart = taskManagerMock.createStart(); + }); + + const buildRequest = () => { + return requestMock.create({ + method: 'delete', + path: RISK_ENGINE_CLEANUP_URL, + body: {}, + }); + }; + describe('invokes the risk engine cleanup route', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + taskManager: mockTaskManagerStart, + security: riskEnginePrivilegesMock.createMockSecurityStartWithFullRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('should call the router with the correct route and handler', async () => { + const request = buildRequest(); + await server.inject(request, context); + expect(mockRiskEngineDataClient.tearDown).toHaveBeenCalled(); + }); + + it('returns a 200 when cleanup is successful', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(200); + expect(response.body).toEqual({ cleanup_successful: true }); + }); + + it('returns a 400 when cleanup endpoint is called multiple times', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(async () => { + return [Error('Risk engine is disabled or deleted already.')]; + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + cleanup_successful: false, + errors: [ + { + seq: 1, + error: 'Error: Risk engine is disabled or deleted already.', + }, + ], + status_code: 400, + }); + }); + + it('returns a 500 when cleanup is unsuccessful', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(() => { + throw new Error('Error tearing down'); + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(500); + expect(response.body).toEqual({ + errors: { + error: '{}', + seq: 1, + }, + cleanup_successful: false, + status_code: 500, + }); + }); + + it('returns a 500 when cleanup is unsuccessful with multiple errors', async () => { + mockRiskEngineDataClient.tearDown.mockImplementation(async () => { + return [ + Error('Error while removing risk scoring task'), + Error('Error while deleting saved objects'), + Error('Error while removing risk score index'), + ]; + }); + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(500); + expect(response.body).toEqual({ + errors: [ + { + seq: 1, + error: 'Error: Error while removing risk scoring task', + }, + { + seq: 2, + error: 'Error: Error while deleting saved objects', + }, + { + seq: 3, + error: 'Error: Error while removing risk score index', + }, + ], + cleanup_successful: false, + status_code: 500, + }); + }); + }); + describe('when task manager is unavailable', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + security: riskEnginePrivilegesMock.createMockSecurityStartWithFullRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('returns a 400 when task manager is unavailable', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: + 'Task Manager is unavailable, but is required by the risk engine. Please enable the taskManager plugin and try again.', + status_code: 400, + }); + }); + }); + + describe('when user does not have the required privileges', () => { + beforeEach(() => { + getStartServicesMock = jest.fn().mockResolvedValue([ + {}, + { + taskManager: mockTaskManagerStart, + security: riskEnginePrivilegesMock.createMockSecurityStartWithNoRiskEngineAccess(), + }, + ]); + riskEngineCleanupRoute(server.router, getStartServicesMock); + }); + + it('returns a 403 when user does not have the required privileges', async () => { + const request = buildRequest(); + const response = await server.inject(request, context); + expect(response.status).toBe(403); + expect(response.body).toEqual({ + message: + 'User is missing risk engine privileges. Missing cluster privileges: manage_index_templates, manage_transform.', + status_code: 403, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts new file mode 100644 index 0000000000000..1776ddcca69b1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/delete.ts @@ -0,0 +1,103 @@ +/* + * 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 { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import type { IKibanaResponse } from '@kbn/core-http-server'; +import { withRiskEnginePrivilegeCheck } from '../risk_engine_privileges'; +import { RISK_ENGINE_CLEANUP_URL, APP_ID, API_VERSIONS } from '../../../../../common/constants'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { RiskEngineAuditActions } from '../audit'; +import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit'; +import { TASK_MANAGER_UNAVAILABLE_ERROR } from './translations'; +import type { CleanUpRiskEngineResponse } from '../../../../../common/api/entity_analytics'; + +export const riskEngineCleanupRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'] +) => { + router.versioned + .delete({ + access: 'public', + path: RISK_ENGINE_CLEANUP_URL, + options: { + tags: ['access:securitySolution', `access:${APP_ID}-entity-analytics`], + }, + }) + .addVersion( + { version: API_VERSIONS.public.v1, validate: {} }, + withRiskEnginePrivilegeCheck( + getStartServices, + async (context, request, response): Promise> => { + const siemResponse = buildSiemResponse(response); + const securitySolution = await context.securitySolution; + const [_, { taskManager }] = await getStartServices(); + const riskEngineClient = securitySolution.getRiskEngineDataClient(); + const riskScoreDataClient = securitySolution.getRiskScoreDataClient(); + + if (!taskManager) { + securitySolution.getAuditLogger()?.log({ + message: + 'User attempted to perform a cleanup of risk engine, but the Kibana Task Manager was unavailable', + event: { + action: RiskEngineAuditActions.RISK_ENGINE_REMOVE_TASK, + category: AUDIT_CATEGORY.DATABASE, + type: AUDIT_TYPE.DELETION, + outcome: AUDIT_OUTCOME.FAILURE, + }, + error: { + message: + 'User attempted to perform a cleanup of risk engine, but the Kibana Task Manager was unavailable', + }, + }); + + return siemResponse.error({ + statusCode: 400, + body: TASK_MANAGER_UNAVAILABLE_ERROR, + }); + } + + try { + const errors = await riskEngineClient.tearDown({ + taskManager, + riskScoreDataClient, + }); + if (errors && errors.length > 0) { + return siemResponse.error({ + statusCode: errors.some((error) => + error.message.includes('Risk engine is disabled or deleted already.') + ) + ? 400 + : 500, + body: { + cleanup_successful: false, + errors: errors.map((error, seq) => ({ + seq: seq + 1, + error: error.toString(), + })), + }, + bypassErrorFormat: true, + }); + } else { + return response.ok({ body: { cleanup_successful: true } }); + } + } catch (error) { + return siemResponse.error({ + statusCode: 500, + body: { + cleanup_successful: false, + errors: { + seq: 1, + error: JSON.stringify(error), + }, + }, + bypassErrorFormat: true, + }); + } + } + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts index 99b0bbe5a5e87..f4edb7d798188 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/register_risk_engine_routes.ts @@ -12,6 +12,7 @@ import { riskEnginePrivilegesRoute } from './privileges'; import { riskEngineSettingsRoute } from './settings'; import type { EntityAnalyticsRoutesDeps } from '../../types'; import { riskEngineScheduleNowRoute } from './schedule_now'; +import { riskEngineCleanupRoute } from './delete'; export const registerRiskEngineRoutes = ({ router, @@ -24,4 +25,5 @@ export const registerRiskEngineRoutes = ({ riskEngineScheduleNowRoute(router, getStartServices); riskEngineSettingsRoute(router); riskEnginePrivilegesRoute(router, getStartServices); + riskEngineCleanupRoute(router, getStartServices); }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts index 10c772cfcf05e..189e72624c15c 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_engine/routes/risk_engine_privileges.mock.ts @@ -29,6 +29,27 @@ const createMockSecurityStartWithFullRiskEngineAccess = () => { return mockSecurityStart; }; +const createMockSecurityStartWithNoRiskEngineAccess = () => { + const mockSecurityStart = securityMock.createStart(); + + const mockCheckPrivileges = jest.fn().mockResolvedValue({ + hasAllRequested: false, + privileges: { + elasticsearch: { + cluster: [], + index: [], + }, + }, + }); + + mockSecurityStart.authz.checkPrivilegesDynamicallyWithRequest = jest + .fn() + .mockReturnValue(mockCheckPrivileges); + + return mockSecurityStart; +}; + export const riskEnginePrivilegesMock = { createMockSecurityStartWithFullRiskEngineAccess, + createMockSecurityStartWithNoRiskEngineAccess, }; diff --git a/x-pack/test/api_integration/services/security_solution_api.gen.ts b/x-pack/test/api_integration/services/security_solution_api.gen.ts index bb229ddcd693f..74f14c2c5f6ad 100644 --- a/x-pack/test/api_integration/services/security_solution_api.gen.ts +++ b/x-pack/test/api_integration/services/security_solution_api.gen.ts @@ -256,6 +256,16 @@ If asset criticality records already exist for the specified entities, those rec .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .send(props.body as object); }, + /** + * Cleaning up the the Risk Engine by removing the indices, mapping and transforms + */ + cleanUpRiskEngine() { + return supertest + .delete('/api/risk_score/engine/dangerously_delete_data') + .set('kbn-xsrf', 'true') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana'); + }, createAlertsIndex() { return supertest .post('/api/detection_engine/index') diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts index 4879cce14f3a6..2aa04a898a449 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/index.ts @@ -10,6 +10,7 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('Entity Analytics - Risk Engine', function () { loadTestFile(require.resolve('./init_and_status_apis')); + loadTestFile(require.resolve('./risk_engine_cleanup_api')); loadTestFile(require.resolve('./risk_score_preview')); loadTestFile(require.resolve('./risk_scoring_task/task_execution')); loadTestFile(require.resolve('./risk_scoring_task/task_execution_nondefault_spaces')); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts new file mode 100644 index 0000000000000..48344403093b3 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/risk_engine/trial_license_complete_tier/risk_engine_cleanup_api.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { v4 as uuidv4 } from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + buildDocument, + riskEngineRouteHelpersFactory, + waitForRiskScoresToBePresent, + createAndSyncRuleAndAlertsFactory, +} from '../../utils'; +import { dataGeneratorFactory } from '../../../detections_response/utils'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const riskEngineRoutes = riskEngineRouteHelpersFactory(supertest); + const es = getService('es'); + const log = getService('log'); + const esArchiver = getService('esArchiver'); + + describe('@ess @ serverless @serverless QA risk_engine_cleanup_api', () => { + const createAndSyncRuleAndAlerts = createAndSyncRuleAndAlertsFactory({ supertest, log }); + const { indexListOfDocuments } = dataGeneratorFactory({ + es, + index: 'ecs_compliant', + log, + }); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/security_solution/ecs_compliant'); + }); + + it('should return response with success status', async () => { + const status1 = await riskEngineRoutes.getStatus(); + expect(status1.body.risk_engine_status).to.be('NOT_INSTALLED'); + expect(status1.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + + const firstDocumentId = uuidv4(); + await indexListOfDocuments([buildDocument({ host: { name: 'host-1' } }, firstDocumentId)]); + await createAndSyncRuleAndAlerts({ query: `id: ${firstDocumentId}` }); + + await riskEngineRoutes.init(); + await waitForRiskScoresToBePresent({ es, log, scoreCount: 1 }); + + const status2 = await riskEngineRoutes.getStatus(); + expect(status2.body.risk_engine_status).to.be('ENABLED'); + expect(status2.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + + const response = await riskEngineRoutes.delete(); + expect(response.body).to.eql({ + cleanup_successful: true, + }); + + const status3 = await riskEngineRoutes.getStatus(); + expect(status3.body.risk_engine_status).to.be('NOT_INSTALLED'); + expect(status3.body.legacy_risk_engine_status).to.be('NOT_INSTALLED'); + }); + }); +}; diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts index 3b96bc61cc7ba..977ab1b3675f9 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/utils/risk_engine.ts @@ -22,6 +22,7 @@ import { RISK_ENGINE_ENABLE_URL, RISK_ENGINE_STATUS_URL, RISK_ENGINE_PRIVILEGES_URL, + RISK_ENGINE_CLEANUP_URL, RISK_ENGINE_SCHEDULE_NOW_URL, } from '@kbn/security-solution-plugin/common/constants'; import { MappingTypeMapping } from '@elastic/elasticsearch/lib/api/types'; @@ -574,6 +575,14 @@ export const riskEngineRouteHelpersFactory = (supertest: SuperTest.Agent, namesp .send() .expect(expectStatusCode), + delete: async (expectStatusCode: number = 200) => + await supertest + .delete(routeWithNamespace(RISK_ENGINE_CLEANUP_URL, namespace)) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '2023-10-31') + .send() + .expect(expectStatusCode), + scheduleNow: async (expectStatusCode: number = 200) => await supertest .post(routeWithNamespace(RISK_ENGINE_SCHEDULE_NOW_URL, namespace)) From 475fd6c2021ffc79e91afae05fa6793f42a4bb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:40:12 +0100 Subject: [PATCH 7/8] [Inventory][ECO] filter by type on grid (#193875) closes https://github.com/elastic/kibana/issues/192669 https://github.com/user-attachments/assets/d8c51a63-6783-4a9c-a785-83004489cd5d --- .../badge_filter_with_popover.test.tsx | 61 +++++++++++ .../badge_filter_with_popover/index.tsx | 103 ++++++++++++++++++ .../entities_grid/entities_grid.stories.tsx | 56 +++++++--- .../public/components/entities_grid/index.tsx | 18 ++- .../public/pages/inventory_page/index.tsx | 13 +++ 5 files changed, 229 insertions(+), 22 deletions(-) create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx new file mode 100644 index 0000000000000..cda2f0bcb42d3 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/badge_filter_with_popover.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { BadgeFilterWithPopover } from '.'; +import { EuiThemeProvider, copyToClipboard } from '@elastic/eui'; +import { ENTITY_TYPE } from '../../../common/es_fields/entities'; + +jest.mock('@elastic/eui', () => ({ + ...jest.requireActual('@elastic/eui'), + copyToClipboard: jest.fn(), +})); + +describe('BadgeFilterWithPopover', () => { + const mockOnFilter = jest.fn(); + const field = ENTITY_TYPE; + const value = 'host'; + const label = 'Host'; + const popoverContentDataTestId = 'inventoryBadgeFilterWithPopoverContent'; + const popoverContentTitleTestId = 'inventoryBadgeFilterWithPopoverTitle'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the badge with the correct label', () => { + render( + , + { wrapper: EuiThemeProvider } + ); + expect(screen.queryByText(label)).toBeInTheDocument(); + expect(screen.getByText(label).textContent).toBe(label); + }); + + it('opens the popover when the badge is clicked', () => { + render(); + expect(screen.queryByTestId(popoverContentDataTestId)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(value)); + expect(screen.queryByTestId(popoverContentDataTestId)).toBeInTheDocument(); + expect(screen.queryByTestId(popoverContentTitleTestId)?.textContent).toBe(`${field}:${value}`); + }); + + it('calls onFilter when the "Filter for" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverFilterForButton')); + expect(mockOnFilter).toHaveBeenCalled(); + }); + + it('copies value to clipboard when the "Copy value" button is clicked', () => { + render(); + fireEvent.click(screen.getByText(value)); + fireEvent.click(screen.getByTestId('inventoryBadgeFilterWithPopoverCopyValueButton')); + expect(copyToClipboard).toHaveBeenCalledWith(value); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx new file mode 100644 index 0000000000000..d1e952e189d6e --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/badge_filter_with_popover/index.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverFooter, + copyToClipboard, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; + +interface Props { + field: string; + value: string; + label?: string; + onFilter: () => void; +} + +export function BadgeFilterWithPopover({ field, value, onFilter, label }: Props) { + const [isOpen, setIsOpen] = useState(false); + const theme = useEuiTheme(); + + return ( + setIsOpen((state) => !state)} + onClickAriaLabel={i18n.translate( + 'xpack.inventory.badgeFilterWithPopover.openPopoverBadgeLabel', + { defaultMessage: 'Open popover' } + )} + > + {label || value} + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + + + + + {field}: + + + + {value} + + + + + + + + {i18n.translate('xpack.inventory.badgeFilterWithPopover.filterForButtonEmptyLabel', { + defaultMessage: 'Filter for', + })} + + + + copyToClipboard(value)} + > + {i18n.translate('xpack.inventory.badgeFilterWithPopover.copyValueButtonEmptyLabel', { + defaultMessage: 'Copy value', + })} + + + + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx index b0e6c2fcc5ee4..996f0ec951581 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import { EuiDataGridSorting } from '@elastic/eui'; +import { EuiDataGridSorting, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; import { Meta, Story } from '@storybook/react'; -import React, { useMemo, useState } from 'react'; import { orderBy } from 'lodash'; +import React, { useMemo, useState } from 'react'; import { EntitiesGrid } from '.'; -import { ENTITY_LAST_SEEN } from '../../../common/es_fields/entities'; +import { EntityType } from '../../../common/entities'; +import { ENTITY_LAST_SEEN, ENTITY_TYPE } from '../../../common/es_fields/entities'; import { entitiesMock } from './mock/entities_mock'; const stories: Meta<{}> = { @@ -25,22 +26,44 @@ export const Example: Story<{}> = () => { id: ENTITY_LAST_SEEN, direction: 'desc', }); - - const sortedItems = useMemo( - () => orderBy(entitiesMock, sort.id, sort.direction), - [sort.direction, sort.id] + const [selectedEntityType, setSelectedEntityType] = useState(); + const filteredAndSortedItems = useMemo( + () => + orderBy( + selectedEntityType + ? entitiesMock.filter((mock) => mock[ENTITY_TYPE] === selectedEntityType) + : entitiesMock, + sort.id, + sort.direction + ), + [selectedEntityType, sort.direction, sort.id] ); return ( - + + + {`Entity filter: ${selectedEntityType || 'N/A'}`} + setSelectedEntityType(undefined)} + > + Clear filter + + + + + + ); }; @@ -60,6 +83,7 @@ export const EmptyGridExample: Story<{}> = () => { onChangePage={setPageIndex} onChangeSort={setSort} pageIndex={pageIndex} + onFilterByType={() => {}} /> ); }; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index e24335b165359..dbd1f0806895a 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -5,7 +5,6 @@ * 2.0. */ import { - EuiBadge, EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, @@ -20,6 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; import { last } from 'lodash'; import React, { useCallback, useState } from 'react'; +import { EntityType } from '../../../common/entities'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, @@ -27,7 +27,7 @@ import { } from '../../../common/es_fields/entities'; import { APIReturnType } from '../../api'; import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; -import { EntityType } from '../../../common/entities'; +import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; @@ -109,6 +109,7 @@ interface Props { pageIndex: number; onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void; onChangePage: (nextPage: number) => void; + onFilterByType: (entityType: EntityType) => void; } const PAGE_SIZE = 20; @@ -121,6 +122,7 @@ export function EntitiesGrid({ pageIndex, onChangePage, onChangeSort, + onFilterByType, }: Props) { const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); @@ -144,10 +146,14 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; switch (columnEntityTableId) { case ENTITY_TYPE: + const entityType = entity[columnEntityTableId] as EntityType; return ( - - {getEntityTypeLabel(entity[columnEntityTableId] as EntityType)} - + onFilterByType(entityType)} + /> ); case ENTITY_LAST_SEEN: return ( @@ -186,7 +192,7 @@ export function EntitiesGrid({ return entity[columnId as EntityColumnIds] || ''; } }, - [entities] + [entities, onFilterByType] ); if (loading) { diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 2fcb147cbe61f..7af9a9fc21acc 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -7,6 +7,7 @@ import { EuiDataGridSorting } from '@elastic/eui'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; +import { EntityType } from '../../../common/entities'; import { EntitiesGrid } from '../../components/entities_grid'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; @@ -81,6 +82,17 @@ export function InventoryPage() { }); } + function handleTypeFilter(entityType: EntityType) { + inventoryRoute.push('/', { + path: {}, + query: { + ...query, + // Override the current entity types + entityTypes: [entityType], + }, + }); + } + return ( ); } From 6c1f18c263709e62ddb0a59f3fff5c3531bd4172 Mon Sep 17 00:00:00 2001 From: Alex Szabo Date: Wed, 25 Sep 2024 14:24:47 +0200 Subject: [PATCH 8/8] Fix type issue resulting from #193415 & #193791 (#193970) ## Summary As the title says. Two competing changes made it in to main (#193415 & #193791), causing a type issue: https://buildkite.com/elastic/kibana-on-merge/builds/50786#019228ae-4487-45c6-867a-ba0590b1266d cc: @davismcphee @stratoula --- .../src/components/default_cell_actions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx index 51730aef44349..f2260a7d910de 100644 --- a/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx +++ b/packages/kbn-unified-data-table/src/components/default_cell_actions.tsx @@ -50,7 +50,7 @@ export const FilterInBtn = ({ }) => { const context = useContext(UnifiedDataTableContext); const filteringDisabled = - isPlainRecord && Array.isArray(context.rows[rowIndex]?.flattened[columnId]); + isPlainRecord && Array.isArray(context.getRowByIndex(rowIndex)?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterForAria', { defaultMessage: 'Filter for this {value}', values: { value: columnId }, @@ -85,7 +85,7 @@ export const FilterOutBtn = ({ }) => { const context = useContext(UnifiedDataTableContext); const filteringDisabled = - isPlainRecord && Array.isArray(context.rows[rowIndex]?.flattened[columnId]); + isPlainRecord && Array.isArray(context.getRowByIndex(rowIndex)?.flattened[columnId]); const buttonTitle = i18n.translate('unifiedDataTable.grid.filterOutAria', { defaultMessage: 'Filter out this {value}', values: { value: columnId },