From 5cc1315dd3792e47106dc04293487388564d50bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alberto=20Bl=C3=A1zquez?= Date: Thu, 16 Jan 2025 16:35:49 +0100 Subject: [PATCH] Implement Asset Inventory data grid (#206115) ## Summary Closes https://github.com/elastic/security-team/issues/11270. ### Screenshots
Current state Screenshot 2025-01-15 at 17 28 42
Current state + RiskBadge + Criticality + SearchBar (implemented in separate PRs) Screenshot 2025-01-13 at 16 34 10
### Definition of done > [!NOTE] > For now it only works with static data until backend is ready - [x] Implement DataGrid using the `` component, based on [[EuiDataGrid](https://eui.elastic.co/#/tabular-content/data-grid)](https://eui.elastic.co/#/tabular-content/data-grid), ensuring consistency with Kibana standards. - [x] Configure columns as follows: - **Action column**: No label; includes a button in each row to expand the `EntityFlyout`. - **Risk**: Numerical indicators representing the asset's risk. - **Name**: The name or identifier of the asset. - **Criticality**: Displays priority or severity levels (e.g., High, Medium, Low). Field `asset.criticality` - **Source**: Represents the asset source (e.g., Host, Storage, Database). `asset.source` - **Last Seen**: Timestamp indicating the last observed data for the asset. - [x] Add static/mock data rows to display paginated asset data, with each row including: - Buttons/icons for expanding the `EntityFlyout`. - [x] Include the following interactive elements: - [x] Multi-sorting: Allow users to sort by multiple columns (e.g., Risk and Criticality). **This only works if fields are added manually to the DataView** - [x] Columns selector: Provide an option for users to show/hide specific columns. - [x] Fullscreen toggle: Allow users to expand the DataGrid to fullscreen mode for enhanced visibility. - [x] Pagination controls: Enable navigation across multiple pages of data. - [x] Rows per page dropdown: Allow users to select the number of rows displayed per page (10, 25, 50, 100, 250, 500). - [x] Enforce constraints: - Limit search results to 500 at a time using `UnifiedDataTable`'s pagination helper for loading more data once the limit is reached. ### Out of scope - Risk score colored badges (implemented in follow-up PR) - Group-by functionality or switching between grid and grouped views - Field selector implementation - Flyout rendering ### Duplicated files > [!CAUTION] > As of now, `` is a complex component that needs to be fed with multiple props. For that, we need several components, hooks and utilities that currently exist within the CSP plugin and are too coupled with it. It's currently not possible to reuse all this logic unless we move that into a separate @kbn-package so I had to temporarily duplicate a bunch of files. This is the list to account them for: - `hooks/` - `use_asset_inventory_data_table/` - `index.ts` - `use_asset_inventory_data_table.ts` - `use_base_es_query.ts` - `use_page_size.ts` - `use_persisted_query.ts` - `use_url_query.ts` - `utils.ts` - `data_view_context.ts` - `use_fields_modal.ts` - `use_styles.ts` - `components/` - `additional_controls.tsx` - `empty_state.tsx` - `fields_selector_modal.tsx` - `fields_selector_table.tsx` This ticket will track progress on this task to remove duplicities and refactor code to have a single source of truth reusable in both Asset Inventory and CSP plugins: - https://github.com/elastic/security-team/issues/11584 ### How to test 1. Open the Index Management page in `http://localhost:5601/kbn/app/management/data/index_management` and click on "Create index". Then type `asset-inventory-logs` in the dialog's input. 2. Open the DataViews page in `http://localhost:5601/kbn/app/management/kibana/dataViews` and click on "Create Data View". 3. Fill in the flyout form typing the following values before clicking on the "Save data view to Kibana" button: - `asset-inventory-logs` in "name" and "index pattern" fields. - `@timestamp` is the value set on the "Timestamp field". - Click on "Show advanced settings", then type `asset-inventory-logs-default` in the "Custom data view ID" field. 4. Open the Inventory page from the Security solution in `http://localhost:5601/kbn/app/security/asset_inventory`.
Data View Example Screenshot 2025-01-10 at 11 09 00
### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [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/src/platform/packages/shared/kbn-i18n/README.md) - [x] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) ### Risks No risks at all. --- .../components/additional_controls.tsx | 87 ++++ .../public/asset_inventory/components/app.tsx | 34 -- .../components/empty_state.tsx | 86 ++++ .../components/fields_selector_modal.tsx | 84 ++++ .../components/fields_selector_table.tsx | 290 ++++++++++++ .../hooks/data_view_context.ts | 30 ++ .../use_asset_inventory_data_table/index.ts | 10 + .../use_asset_inventory_data_table.ts | 178 ++++++++ .../use_base_es_query.ts | 93 ++++ .../use_page_size.ts | 27 ++ .../use_persisted_query.ts | 28 ++ .../use_url_query.ts | 45 ++ .../use_asset_inventory_data_table/utils.ts | 14 + .../asset_inventory/hooks/use_fields_modal.ts | 21 + .../asset_inventory/hooks/use_styles.ts | 80 ++++ .../asset_inventory/pages/all_assets.tsx | 431 ++++++++++++++++++ .../public/asset_inventory/pages/index.tsx | 24 - .../public/asset_inventory/routes.tsx | 61 ++- .../public/asset_inventory/sample_data.ts | 109 +++++ 19 files changed, 1662 insertions(+), 70 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/additional_controls.tsx delete mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/app.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/empty_state.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_modal.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_table.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/data_view_context.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/index.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_asset_inventory_data_table.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_base_es_query.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_page_size.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_persisted_query.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_url_query.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/utils.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fields_modal.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_styles.ts create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/all_assets.tsx delete mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/index.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/asset_inventory/sample_data.ts diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/additional_controls.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/additional_controls.tsx new file mode 100644 index 0000000000000..208649f846e0c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/additional_controls.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { type FC, type PropsWithChildren } from 'react'; +import { EuiButtonEmpty, EuiFlexItem } from '@elastic/eui'; +import { type DataView } from '@kbn/data-views-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { getAbbreviatedNumber } from '@kbn/cloud-security-posture-common'; +import { FieldsSelectorModal } from './fields_selector_modal'; +import { useFieldsModal } from '../hooks/use_fields_modal'; +import { useStyles } from '../hooks/use_styles'; + +const ASSET_INVENTORY_FIELDS_SELECTOR_OPEN_BUTTON = 'assetInventoryFieldsSelectorOpenButton'; + +const GroupSelectorWrapper: FC> = ({ children }) => { + const styles = useStyles(); + + return ( + + {children} + + ); +}; + +export const AdditionalControls = ({ + total, + title, + dataView, + columns, + onAddColumn, + onRemoveColumn, + groupSelectorComponent, + onResetColumns, +}: { + total: number; + title: string; + dataView: DataView; + columns: string[]; + onAddColumn: (column: string) => void; + onRemoveColumn: (column: string) => void; + groupSelectorComponent?: JSX.Element; + onResetColumns: () => void; +}) => { + const { isFieldSelectorModalVisible, closeFieldsSelectorModal, openFieldsSelectorModal } = + useFieldsModal(); + + return ( + <> + {isFieldSelectorModalVisible && ( + + )} + + {`${getAbbreviatedNumber( + total + )} ${title}`} + + + + + + + {groupSelectorComponent && ( + {groupSelectorComponent} + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/app.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/app.tsx deleted file mode 100644 index 837d7f007aab1..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/app.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React from 'react'; -import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; -import { EuiPageTemplate, EuiTitle } from '@elastic/eui'; - -const AssetInventoryApp = () => { - return ( - - <> - - - -

- -

-
-
- -
- -
- ); -}; - -// we need to use default exports to import it via React.lazy -export default AssetInventoryApp; // eslint-disable-line import/no-default-export diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/empty_state.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/empty_state.tsx new file mode 100644 index 0000000000000..42460408f670a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/empty_state.tsx @@ -0,0 +1,86 @@ +/* + * 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 { EuiImage, EuiEmptyPrompt, EuiButton, EuiLink, useEuiTheme } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import illustration from '../../common/images/illustration_product_no_results_magnifying_glass.svg'; + +const ASSET_INVENTORY_DOCS_URL = 'https://ela.st/asset-inventory'; +const EMPTY_STATE_TEST_SUBJ = 'assetInventory:empty-state'; + +export const EmptyState = ({ + onResetFilters, + docsUrl = ASSET_INVENTORY_DOCS_URL, +}: { + onResetFilters: () => void; + docsUrl?: string; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + .euiEmptyPrompt__main { + gap: ${euiTheme.size.xl}; + } + && { + margin-top: ${euiTheme.size.xxxl}}; + } + `} + data-test-subj={EMPTY_STATE_TEST_SUBJ} + icon={ + + } + title={ +

+ +

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

+ +

+ + } + actions={[ + + + , + + + , + ]} + /> + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_modal.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_modal.tsx new file mode 100644 index 0000000000000..38e9e1837c3e6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_modal.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { type DataView } from '@kbn/data-views-plugin/common'; +import { FieldsSelectorTable } from './fields_selector_table'; + +const ASSET_INVENTORY_FIELDS_SELECTOR_MODAL = 'assetInventoryFieldsSelectorModal'; +const ASSET_INVENTORY_FIELDS_SELECTOR_RESET_BUTTON = 'assetInventoryFieldsSelectorResetButton'; +const ASSET_INVENTORY_FIELDS_SELECTOR_CLOSE_BUTTON = 'assetInventoryFieldsSelectorCloseButton'; + +interface FieldsSelectorModalProps { + dataView: DataView; + columns: string[]; + onAddColumn: (column: string) => void; + onRemoveColumn: (column: string) => void; + closeModal: () => void; + onResetColumns: () => void; +} + +const title = i18n.translate('xpack.securitySolution.assetInventory.dataTable.fieldsModalTitle', { + defaultMessage: 'Fields', +}); + +export const FieldsSelectorModal = ({ + closeModal, + dataView, + columns, + onAddColumn, + onRemoveColumn, + onResetColumns, +}: FieldsSelectorModalProps) => { + return ( + + + {title} + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_table.tsx new file mode 100644 index 0000000000000..65bcb08399a48 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/fields_selector_table.tsx @@ -0,0 +1,290 @@ +/* + * 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, { useCallback, useMemo, useState } from 'react'; +import useSessionStorage from 'react-use/lib/useSessionStorage'; +import { + type CriteriaWithPagination, + type EuiBasicTableColumn, + type EuiSearchBarProps, + EuiButtonEmpty, + EuiCheckbox, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiInMemoryTable, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import type { DataView, DataViewField } from '@kbn/data-views-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +const SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED = 'assetInventory:fieldsModal:showSelected'; +const ACTION_COLUMN_WIDTH = '24px'; +const defaultSorting = { + sort: { + field: 'name', + direction: 'asc', + }, +} as const; + +interface Field { + id: string; + name: string; + displayName: string; +} + +const VIEW_LABEL = i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.fieldsModal.viewLabel', + { + defaultMessage: 'View', + } +); + +const VIEW_VALUE_SELECTED = i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.fieldsModal.viewSelected', + { + defaultMessage: 'selected', + } +); + +const VIEW_VALUE_ALL = i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.fieldsModal.viewAll', + { + defaultMessage: 'all', + } +); + +export interface FieldsSelectorTableProps { + dataView: DataView; + columns: string[]; + onAddColumn: (column: string) => void; + onRemoveColumn: (column: string) => void; + title: string; +} + +export const filterFieldsBySearch = ( + fields: DataViewField[], + visibleColumns: string[] = [], + searchQuery?: string, + isFilterSelectedEnabled: boolean = false +) => { + const allowedFields = fields + .filter((field) => field.name !== '_index' && field.visualizable) + .map((field) => ({ + id: field.name, + name: field.name, + displayName: field.customLabel || '', + })); + + const visibleFields = !isFilterSelectedEnabled + ? allowedFields + : allowedFields.filter((field) => visibleColumns.includes(field.id)); + + return !searchQuery + ? visibleFields + : visibleFields.filter((field) => { + const normalizedName = `${field.name} ${field.displayName}`.toLowerCase(); + const normalizedQuery = searchQuery.toLowerCase() || ''; + return normalizedName.indexOf(normalizedQuery) !== -1; + }); +}; + +export const FieldsSelectorTable = ({ + title, + dataView, + columns, + onAddColumn, + onRemoveColumn, +}: FieldsSelectorTableProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(); + const [isFilterSelectedEnabled, setIsFilterSelectedEnabled] = useSessionStorage( + SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED, + false + ); + const [pagination, setPagination] = useState({ pageIndex: 0 }); + const onTableChange = ({ page: { index } }: CriteriaWithPagination) => { + setPagination({ pageIndex: index }); + }; + const fields = useMemo( + () => + filterFieldsBySearch(dataView.fields.getAll(), columns, searchQuery, isFilterSelectedEnabled), + [dataView, columns, searchQuery, isFilterSelectedEnabled] + ); + + const togglePopover = useCallback(() => { + setIsPopoverOpen((open) => !open); + }, []); + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const onFilterSelectedChange = useCallback( + (enabled: boolean) => { + setIsFilterSelectedEnabled(enabled); + }, + [setIsFilterSelectedEnabled] + ); + + let debounceTimeoutId: ReturnType; + + const onQueryChange: EuiSearchBarProps['onChange'] = ({ query }) => { + clearTimeout(debounceTimeoutId); + + debounceTimeoutId = setTimeout(() => { + setSearchQuery(query?.text); + }, 300); + }; + + const tableColumns: Array> = [ + { + field: 'action', + name: '', + width: ACTION_COLUMN_WIDTH, + sortable: false, + render: (_, { id }: Field) => ( + { + const isChecked = e.target.checked; + return isChecked ? onAddColumn(id) : onRemoveColumn(id); + }} + /> + ), + }, + { + field: 'name', + name: i18n.translate('xpack.securitySolution.assetInventory.allAssets.fieldsModalName', { + defaultMessage: 'Name', + }), + sortable: true, + }, + ]; + + const error = useMemo(() => { + if (!dataView || dataView.fields.length === 0) { + return i18n.translate('xpack.securitySolution.assetInventory.allAssets.fieldsModalError', { + defaultMessage: 'No fields found in the data view', + }); + } + return ''; + }, [dataView]); + + const search: EuiSearchBarProps = { + onChange: onQueryChange, + box: { + incremental: true, + placeholder: i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.fieldsModalSearch', + { + defaultMessage: 'Search field name', + } + ), + }, + }; + + const tableHeader = useMemo(() => { + const totalFields = fields.length; + return ( + + + + {' '} + + {totalFields} + {' '} + + + + + + {`${VIEW_LABEL}: ${isFilterSelectedEnabled ? VIEW_VALUE_SELECTED : VIEW_VALUE_ALL}`} + + } + > + { + onFilterSelectedChange(false); + closePopover(); + }} + > + {`${VIEW_LABEL} ${VIEW_VALUE_ALL}`} + , + , + { + onFilterSelectedChange(true); + closePopover(); + }} + > + {`${VIEW_LABEL} ${VIEW_VALUE_SELECTED}`} + , + ]} + /> + + + + ); + }, [ + closePopover, + fields.length, + isFilterSelectedEnabled, + isPopoverOpen, + onFilterSelectedChange, + togglePopover, + ]); + + return ( + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/data_view_context.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/data_view_context.ts new file mode 100644 index 0000000000000..b430d53407616 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/data_view_context.ts @@ -0,0 +1,30 @@ +/* + * 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 { createContext, useContext } from 'react'; +import type { DataView } from '@kbn/data-views-plugin/common'; + +interface DataViewContextValue { + dataView: DataView; + dataViewRefetch?: () => void; + dataViewIsLoading?: boolean; + dataViewIsRefetching?: boolean; +} + +export const DataViewContext = createContext(undefined); + +/** + * Retrieve context's properties + */ +export const useDataViewContext = (): DataViewContextValue => { + const contextValue = useContext(DataViewContext); + + if (!contextValue) { + throw new Error('useDataViewContext can only be used within DataViewContext provider'); + } + + return contextValue; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/index.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/index.ts new file mode 100644 index 0000000000000..6e3efaec5fc1d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './use_asset_inventory_data_table'; +export * from './use_base_es_query'; +export * from './use_persisted_query'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_asset_inventory_data_table.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_asset_inventory_data_table.ts new file mode 100644 index 0000000000000..741bdaebaae45 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_asset_inventory_data_table.ts @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { type Dispatch, type SetStateAction, useCallback } from 'react'; +import type { BoolQuery, Filter, Query } from '@kbn/es-query'; +import type { CriteriaWithPagination } from '@elastic/eui'; +import type { DataTableRecord } from '@kbn/discover-utils/types'; +import { useUrlQuery } from './use_url_query'; +import { usePageSize } from './use_page_size'; +import { getDefaultQuery } from './utils'; +import { useBaseEsQuery } from './use_base_es_query'; +import { usePersistedQuery } from './use_persisted_query'; + +const LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY = 'assetInventory:dataTable:columns'; + +export interface AssetsBaseURLQuery { + query: Query; + filters: Filter[]; + /** + * Filters that are part of the query but not persisted in the URL or in the Filter Manager + */ + nonPersistedFilters?: Filter[]; + /** + * Grouping component selection + */ + groupBy?: string[]; +} + +export type URLQuery = AssetsBaseURLQuery & Record; + +type SortOrder = [string, string]; + +export interface AssetInventoryDataTableResult { + setUrlQuery: (query: Record) => void; + sort: SortOrder[]; + filters: Filter[]; + query: { bool: BoolQuery }; + queryError?: Error; + pageIndex: number; + urlQuery: URLQuery; + setTableOptions: (options: CriteriaWithPagination) => void; + handleUpdateQuery: (query: URLQuery) => void; + pageSize: number; + setPageSize: Dispatch>; + onChangeItemsPerPage: (newPageSize: number) => void; + onChangePage: (newPageIndex: number) => void; + onSort: (sort: string[][]) => void; + onResetFilters: () => void; + columnsLocalStorageKey: string; + getRowsFromPages: (data: Array<{ page: DataTableRecord[] }> | undefined) => DataTableRecord[]; +} + +/* + Hook for managing common table state and methods for the Asset Inventory DataTable +*/ +export const useAssetInventoryDataTable = ({ + defaultQuery = getDefaultQuery, + paginationLocalStorageKey, + columnsLocalStorageKey, + nonPersistedFilters, +}: { + defaultQuery?: (params: AssetsBaseURLQuery) => URLQuery; + paginationLocalStorageKey: string; + columnsLocalStorageKey?: string; + nonPersistedFilters?: Filter[]; +}): AssetInventoryDataTableResult => { + const getPersistedDefaultQuery = usePersistedQuery(defaultQuery); + const { urlQuery, setUrlQuery } = useUrlQuery(getPersistedDefaultQuery); + const { pageSize, setPageSize } = usePageSize(paginationLocalStorageKey); + + const onChangeItemsPerPage = useCallback( + (newPageSize: number) => { + setPageSize(newPageSize); + setUrlQuery({ + pageIndex: 0, + pageSize: newPageSize, + }); + }, + [setPageSize, setUrlQuery] + ); + + const onResetFilters = useCallback(() => { + setUrlQuery({ + pageIndex: 0, + filters: [], + query: { + query: '', + language: 'kuery', + }, + }); + }, [setUrlQuery]); + + const onChangePage = useCallback( + (newPageIndex: number) => { + setUrlQuery({ + pageIndex: newPageIndex, + }); + }, + [setUrlQuery] + ); + + const onSort = useCallback( + (sort: string[][]) => { + setUrlQuery({ + sort, + }); + }, + [setUrlQuery] + ); + + const setTableOptions = useCallback( + ({ page, sort }: CriteriaWithPagination) => { + setPageSize(page.size); + setUrlQuery({ + sort, + pageIndex: page.index, + }); + }, + [setUrlQuery, setPageSize] + ); + + /** + * Page URL query to ES query + */ + const baseEsQuery = useBaseEsQuery({ + filters: urlQuery.filters, + query: urlQuery.query, + ...(nonPersistedFilters ? { nonPersistedFilters } : {}), + }); + + const handleUpdateQuery = useCallback( + (query: URLQuery) => { + setUrlQuery({ ...query, pageIndex: 0 }); + }, + [setUrlQuery] + ); + + const getRowsFromPages = (data: Array<{ page: DataTableRecord[] }> | undefined) => + data + ?.map(({ page }: { page: DataTableRecord[] }) => { + return page; + }) + .flat() || []; + + const queryError = baseEsQuery instanceof Error ? baseEsQuery : undefined; + + return { + setUrlQuery, + sort: urlQuery.sort as SortOrder[], + filters: urlQuery.filters || [], + query: baseEsQuery.query + ? baseEsQuery.query + : { + bool: { + must: [], + filter: [], + should: [], + must_not: [], + }, + }, + queryError, + pageIndex: urlQuery.pageIndex as number, + urlQuery, + setTableOptions, + handleUpdateQuery, + pageSize, + setPageSize, + onChangeItemsPerPage, + onChangePage, + onSort, + onResetFilters, + columnsLocalStorageKey: columnsLocalStorageKey || LOCAL_STORAGE_DATA_TABLE_COLUMNS_KEY, + getRowsFromPages, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_base_es_query.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_base_es_query.ts new file mode 100644 index 0000000000000..d7ea573617e86 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_base_es_query.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DataView } from '@kbn/data-views-plugin/common'; +import { buildEsQuery, type EsQueryConfig } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { useEffect, useMemo } from 'react'; +import { useDataViewContext } from '../data_view_context'; +import { useKibana } from '../../../common/lib/kibana'; +import type { AssetsBaseURLQuery } from './use_asset_inventory_data_table'; + +interface AssetsBaseESQueryConfig { + config: EsQueryConfig; +} + +const getBaseQuery = ({ + dataView, + query, + filters, + config, +}: AssetsBaseURLQuery & + AssetsBaseESQueryConfig & { + dataView: DataView | undefined; + }) => { + try { + return { + query: buildEsQuery(dataView, query, filters, config), // will throw for malformed query + }; + } catch (error) { + return { + query: undefined, + error: error instanceof Error ? error : new Error('Unknown Error'), + }; + } +}; + +export const useBaseEsQuery = ({ + filters = [], + query, + nonPersistedFilters, +}: AssetsBaseURLQuery) => { + const { + notifications: { toasts }, + data: { + query: { filterManager, queryString }, + }, + uiSettings, + } = useKibana().services; + const { dataView } = useDataViewContext(); + const allowLeadingWildcards = uiSettings.get('query:allowLeadingWildcards'); + const config: EsQueryConfig = useMemo(() => ({ allowLeadingWildcards }), [allowLeadingWildcards]); + const baseEsQuery = useMemo( + () => + getBaseQuery({ + dataView, + filters: filters.concat(nonPersistedFilters ?? []).flat(), + query, + config, + }), + [dataView, filters, nonPersistedFilters, query, config] + ); + + /** + * Sync filters with the URL query + */ + useEffect(() => { + filterManager.setAppFilters(filters); + queryString.setQuery(query); + }, [filters, filterManager, queryString, query]); + + const handleMalformedQueryError = () => { + const error = baseEsQuery instanceof Error ? baseEsQuery : undefined; + if (error) { + toasts.addError(error, { + title: i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.search.queryErrorToastMessage', + { + defaultMessage: 'Query Error', + } + ), + toastLifeTimeMs: 1000 * 5, + }); + } + }; + + useEffect(handleMalformedQueryError, [baseEsQuery, toasts]); + + return baseEsQuery; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_page_size.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_page_size.ts new file mode 100644 index 0000000000000..396aef75509c5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_page_size.ts @@ -0,0 +1,27 @@ +/* + * 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 useLocalStorage from 'react-use/lib/useLocalStorage'; + +const DEFAULT_VISIBLE_ROWS_PER_PAGE = 10; // generic default # of table rows to show (currently we only have a list of policies) + +/** + * @description handles persisting the users table row size selection + */ +export const usePageSize = (localStorageKey: string) => { + const [persistedPageSize, setPersistedPageSize] = useLocalStorage( + localStorageKey, + DEFAULT_VISIBLE_ROWS_PER_PAGE + ); + + let pageSize = DEFAULT_VISIBLE_ROWS_PER_PAGE; + + if (persistedPageSize) { + pageSize = persistedPageSize; + } + + return { pageSize, setPageSize: setPersistedPageSize }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_persisted_query.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_persisted_query.ts new file mode 100644 index 0000000000000..0b2b4eb76d9f8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_persisted_query.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import type { Query } from '@kbn/es-query'; +import type { AssetsBaseURLQuery } from './use_asset_inventory_data_table'; +import { useKibana } from '../../../common/lib/kibana'; + +export const usePersistedQuery = (getter: ({ filters, query }: AssetsBaseURLQuery) => T) => { + const { + data: { + query: { filterManager, queryString }, + }, + } = useKibana().services; + + return useCallback( + () => + getter({ + filters: filterManager.getAppFilters(), + query: queryString.getQuery() as Query, + }), + [getter, filterManager, queryString] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_url_query.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_url_query.ts new file mode 100644 index 0000000000000..144fffda6e2d7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/use_url_query.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { useEffect, useCallback, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; +import { encodeQuery, decodeQuery } from '@kbn/cloud-security-posture'; + +/** + * @description uses 'rison' to encode/decode a url query + * @todo replace getDefaultQuery with schema. validate after decoded from URL, use defaultValues + * @note shallow-merges default, current and next query + */ +export const useUrlQuery = (getDefaultQuery: () => T) => { + const { push, replace } = useHistory(); + const { search, key } = useLocation(); + + const urlQuery = useMemo( + () => ({ ...getDefaultQuery(), ...decodeQuery(search) }), + [getDefaultQuery, search] + ); + + const setUrlQuery = useCallback( + (query: Partial) => + push({ + search: encodeQuery({ ...getDefaultQuery(), ...urlQuery, ...query }), + }), + [getDefaultQuery, urlQuery, push] + ); + + // Set initial query + useEffect(() => { + if (search) return; + + replace({ search: encodeQuery(getDefaultQuery()) }); + }, [getDefaultQuery, search, replace]); + + return { + key, + urlQuery, + setUrlQuery, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/utils.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/utils.ts new file mode 100644 index 0000000000000..565ed333edc70 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_asset_inventory_data_table/utils.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { AssetsBaseURLQuery } from './use_asset_inventory_data_table'; + +export const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery) => ({ + query, + filters, + sort: { field: '@timestamp', direction: 'desc' }, + pageIndex: 0, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fields_modal.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fields_modal.ts new file mode 100644 index 0000000000000..9ca88d3d97b76 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fields_modal.ts @@ -0,0 +1,21 @@ +/* + * 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 { useState } from 'react'; + +export const useFieldsModal = () => { + const [isFieldSelectorModalVisible, setIsFieldSelectorModalVisible] = useState(false); + + const closeFieldsSelectorModal = () => setIsFieldSelectorModalVisible(false); + const openFieldsSelectorModal = () => setIsFieldSelectorModalVisible(true); + + return { + isFieldSelectorModalVisible, + closeFieldsSelectorModal, + openFieldsSelectorModal, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_styles.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_styles.ts new file mode 100644 index 0000000000000..878c37e7c43a2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_styles.ts @@ -0,0 +1,80 @@ +/* + * 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 { useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/css'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const gridContainer = css` + min-height: 400px; + `; + + const gridStyle = css` + & .euiDataGridHeaderCell__icon { + display: none; + } + & .euiDataGrid__controls { + border-bottom: none; + margin-bottom: ${euiTheme.size.s}; + border-top: none; + } + & .euiDataGrid--headerUnderline .euiDataGridHeaderCell { + border-bottom: ${euiTheme.border.width.thick} solid ${euiTheme.colors.fullShade}; + } + & .euiButtonIcon[data-test-subj='docTableExpandToggleColumn'] { + color: ${euiTheme.colors.primary}; + } + + & .euiDataGridRowCell { + font-size: ${euiTheme.size.m}; + + // Vertically center content + .euiDataGridRowCell__content { + display: flex; + align-items: center; + } + } + & .euiDataGridRowCell.euiDataGridRowCell--numeric { + text-align: left; + } + & .euiDataGridHeaderCell--numeric .euiDataGridHeaderCell__content { + flex-grow: 0; + text-align: left; + } + & .assetInventoryDataTableTotal { + font-size: ${euiTheme.size.m}; + font-weight: ${euiTheme.font.weight.bold}; + border-right: ${euiTheme.border.thin}; + margin-inline: ${euiTheme.size.s}; + padding-right: ${euiTheme.size.m}; + } + + & [data-test-subj='docTableExpandToggleColumn'] svg { + inline-size: 16px; + block-size: 16px; + } + + & .unifiedDataTable__cellValue { + font-family: ${euiTheme.font.family}; + } + & .unifiedDataTable__inner .euiDataGrid__controls { + border-top: none; + } + `; + + const groupBySelector = css` + margin-left: auto; + `; + + return { + gridStyle, + groupBySelector, + gridContainer, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/all_assets.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/all_assets.tsx new file mode 100644 index 0000000000000..9a0fa6fef37b5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/all_assets.tsx @@ -0,0 +1,431 @@ +/* + * 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, { useState, useMemo } from 'react'; +import _ from 'lodash'; +import { type Filter } from '@kbn/es-query'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n-react'; +import { + UnifiedDataTable, + DataLoadingState, + DataGridDensity, + useColumns, + type UnifiedDataTableSettings, + type UnifiedDataTableSettingsColumn, +} from '@kbn/unified-data-table'; +import { CellActionsProvider } from '@kbn/cell-actions'; +import { type HttpSetup } from '@kbn/core-http-browser'; +import { SHOW_MULTIFIELDS, SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils'; +import { type DataTableRecord } from '@kbn/discover-utils/types'; +import { + type EuiDataGridCellValueElementProps, + type EuiDataGridControlColumn, + type EuiDataGridStyle, + EuiProgress, + EuiPageTemplate, + EuiTitle, + EuiButtonIcon, +} from '@elastic/eui'; +import { type AddFieldFilterHandler } from '@kbn/unified-field-list'; +import { generateFilters } from '@kbn/data-plugin/public'; +import { type DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + +import { type CriticalityLevelWithUnassigned } from '../../../common/entity_analytics/asset_criticality/types'; +import { useKibana } from '../../common/lib/kibana'; + +import { EmptyState } from '../components/empty_state'; +import { AdditionalControls } from '../components/additional_controls'; + +import { useDataViewContext } from '../hooks/data_view_context'; +import { useStyles } from '../hooks/use_styles'; +import { + useAssetInventoryDataTable, + type AssetsBaseURLQuery, + type URLQuery, +} from '../hooks/use_asset_inventory_data_table'; + +const gridStyle: EuiDataGridStyle = { + border: 'horizontal', + cellPadding: 'l', + stripes: false, + header: 'underline', +}; + +const MAX_ASSETS_TO_LOAD = 500; // equivalent to MAX_FINDINGS_TO_LOAD in @kbn/cloud-security-posture-common + +const title = i18n.translate('xpack.securitySolution.assetInventory.allAssets.tableRowTypeLabel', { + defaultMessage: 'assets', +}); + +const columnsLocalStorageKey = 'assetInventoryColumns'; +const LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY = 'assetInventory:dataTable:pageSize'; + +const columnHeaders: Record = { + 'asset.risk': i18n.translate('xpack.securitySolution.assetInventory.allAssets.risk', { + defaultMessage: 'Risk', + }), + 'asset.name': i18n.translate('xpack.securitySolution.assetInventory.allAssets.name', { + defaultMessage: 'Name', + }), + 'asset.criticality': i18n.translate( + 'xpack.securitySolution.assetInventory.allAssets.criticality', + { + defaultMessage: 'Criticality', + } + ), + 'asset.source': i18n.translate('xpack.securitySolution.assetInventory.allAssets.source', { + defaultMessage: 'Source', + }), + '@timestamp': i18n.translate('xpack.securitySolution.assetInventory.allAssets.lastSeen', { + defaultMessage: 'Last Seen', + }), +} as const; + +const customCellRenderer = (rows: DataTableRecord[]) => ({ + 'asset.risk': ({ rowIndex }: EuiDataGridCellValueElementProps) => { + const risk = rows[rowIndex].flattened['asset.risk'] as number; + return risk; + }, + 'asset.criticality': ({ rowIndex }: EuiDataGridCellValueElementProps) => { + const criticality = rows[rowIndex].flattened[ + 'asset.criticality' + ] as CriticalityLevelWithUnassigned; + return criticality; + }, +}); + +interface AssetInventoryDefaultColumn { + id: string; + width?: number; +} + +const defaultColumns: AssetInventoryDefaultColumn[] = [ + { id: 'asset.risk', width: 50 }, + { id: 'asset.name', width: 400 }, + { id: 'asset.criticality' }, + { id: 'asset.source' }, + { id: '@timestamp' }, +]; + +const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery): URLQuery => ({ + query, + filters, + sort: [['@timestamp', 'desc']], +}); + +export interface AllAssetsProps { + rows: DataTableRecord[]; + isLoading: boolean; + height?: number | string; + loadMore: () => void; + nonPersistedFilters?: Filter[]; + hasDistributionBar?: boolean; + /** + * This function will be used in the control column to create a rule for a specific finding. + */ + createFn?: (rowIndex: number) => ((http: HttpSetup) => Promise) | undefined; + /** + * This is the component that will be rendered in the flyout when a row is expanded. + * This component will receive the row data and a function to close the flyout. + */ + flyoutComponent: (hit: DataTableRecord, onCloseFlyout: () => void) => JSX.Element; + 'data-test-subj'?: string; +} + +const AllAssets = ({ + rows, + isLoading, + loadMore, + nonPersistedFilters, + height, + hasDistributionBar = true, + createFn, + flyoutComponent, + ...rest +}: AllAssetsProps) => { + const assetInventoryDataTable = useAssetInventoryDataTable({ + paginationLocalStorageKey: LOCAL_STORAGE_DATA_TABLE_PAGE_SIZE_KEY, + columnsLocalStorageKey, + defaultQuery: getDefaultQuery, + nonPersistedFilters, + }); + + const { + // columnsLocalStorageKey, + pageSize, + onChangeItemsPerPage, + setUrlQuery, + onSort, + onResetFilters, + filters, + sort, + } = assetInventoryDataTable; + + const [columns, setColumns] = useLocalStorage( + columnsLocalStorageKey, + defaultColumns.map((c) => c.id) + ); + + const [persistedSettings, setPersistedSettings] = useLocalStorage( + `${columnsLocalStorageKey}:settings`, + { + columns: defaultColumns.reduce((columnSettings, column) => { + const columnDefaultSettings = column.width ? { width: column.width } : {}; + const newColumn = { [column.id]: columnDefaultSettings }; + return { ...columnSettings, ...newColumn }; + }, {} as UnifiedDataTableSettings['columns']), + } + ); + + const settings = useMemo(() => { + return { + columns: Object.keys(persistedSettings?.columns as UnifiedDataTableSettings).reduce( + (columnSettings, columnId) => { + const newColumn: UnifiedDataTableSettingsColumn = { + ..._.pick(persistedSettings?.columns?.[columnId], ['width']), + display: columnHeaders?.[columnId], + }; + + return { ...columnSettings, [columnId]: newColumn }; + }, + {} as UnifiedDataTableSettings['columns'] + ), + }; + }, [persistedSettings]); + + const { dataView, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext(); + + const [expandedDoc, setExpandedDoc] = useState(undefined); + + const renderDocumentView = (hit: DataTableRecord) => + flyoutComponent(hit, () => setExpandedDoc(undefined)); + + const { + uiActions, + uiSettings, + dataViews, + data, + application, + theme, + fieldFormats, + notifications, + storage, + } = useKibana().services; + + const styles = useStyles(); + + const { capabilities } = application; + const { filterManager } = data.query; + + const services = { + theme, + fieldFormats, + uiSettings, + toastNotifications: notifications.toasts, + storage, + data, + }; + + const { + columns: currentColumns, + onSetColumns, + onAddColumn, + onRemoveColumn, + } = useColumns({ + capabilities, + defaultOrder: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + dataView, + dataViews, + setAppState: (props) => setColumns(props.columns), + columns, + sort, + }); + + /** + * This object is used to determine if the table rendering will be virtualized and the virtualization wrapper height. + * mode should be passed as a key to the UnifiedDataTable component to force a re-render when the mode changes. + */ + const computeDataTableRendering = useMemo(() => { + // Enable virtualization mode when the table is set to a large page size. + const isVirtualizationEnabled = pageSize >= 100; + + const getWrapperHeight = () => { + if (height) return height; + + // If virtualization is not needed the table will render unconstrained. + if (!isVirtualizationEnabled) return 'auto'; + + const baseHeight = 362; // height of Kibana Header + Findings page header and search bar + const filterBarHeight = filters?.length > 0 ? 40 : 0; + const distributionBarHeight = hasDistributionBar ? 52 : 0; + return `calc(100vh - ${baseHeight}px - ${filterBarHeight}px - ${distributionBarHeight}px)`; + }; + + return { + wrapperHeight: getWrapperHeight(), + mode: isVirtualizationEnabled ? 'virtualized' : 'standard', + }; + }, [pageSize, height, filters?.length, hasDistributionBar]); + + const onAddFilter: AddFieldFilterHandler | undefined = useMemo( + () => + filterManager && dataView + ? (clickedField, values, operation) => { + const newFilters = generateFilters( + filterManager, + clickedField, + values, + operation, + dataView + ); + filterManager.addFilters(newFilters); + setUrlQuery({ + filters: filterManager.getFilters(), + }); + } + : undefined, + [dataView, filterManager, setUrlQuery] + ); + + const onResize = (colSettings: { columnId: string; width: number | undefined }) => { + const grid = persistedSettings || {}; + const newColumns = { ...(grid.columns || {}) }; + newColumns[colSettings.columnId] = colSettings.width + ? { width: Math.round(colSettings.width) } + : {}; + const newGrid = { ...grid, columns: newColumns }; + setPersistedSettings(newGrid); + }; + + const externalCustomRenderers = useMemo(() => { + if (!customCellRenderer) { + return undefined; + } + return customCellRenderer(rows); + }, [rows]); + + const onResetColumns = () => { + setColumns(defaultColumns.map((c) => c.id)); + }; + + const externalAdditionalControls = ( + + ); + + const externalControlColumns: EuiDataGridControlColumn[] = [ + { + id: 'take-action', + width: 20, + headerCellRender: () => null, + rowCellRender: ({ rowIndex }) => ( + createFn(rowIndex)} + /> + ), + }, + ]; + + const loadingStyle = { + opacity: isLoading ? 1 : 0, + }; + + const loadingState = + isLoading || dataViewIsLoading || dataViewIsRefetching || !dataView + ? DataLoadingState.loading + : DataLoadingState.loaded; + + // TODO Improve this loading - prevent race condition fetching rows and dataView + if (loadingState === DataLoadingState.loaded && !rows.length && !!dataView) { + return ; + } + + return ( + + + +

+ +

+
+ +
+ + {!dataView ? null : ( + + )} +
+
+
+
+ ); +}; + +// we need to use default exports to import it via React.lazy +export default AllAssets; // eslint-disable-line import/no-default-export diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/index.tsx deleted file mode 100644 index 006148ed87d9e..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/pages/index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { lazy, Suspense } from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; - -const AssetInventoryLazy = lazy(() => import('../components/app')); - -export const AssetInventoryContainer = React.memo(() => { - return ( - - }> - - - - ); -}); - -AssetInventoryContainer.displayName = 'AssetInventoryContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/routes.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/routes.tsx index 9021ba17c6e2a..25141006f836b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/routes.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/routes.tsx @@ -5,15 +5,30 @@ * 2.0. */ -import React from 'react'; +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - +import { useDataView } from '@kbn/cloud-security-posture/src/hooks/use_data_view'; import type { SecuritySubPluginRoutes } from '../app/types'; import { SecurityPageName } from '../app/types'; import { ASSET_INVENTORY_PATH } from '../../common/constants'; +import { SecuritySolutionPageWrapper } from '../common/components/page_wrapper'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper'; -import { AssetInventoryContainer } from './pages'; +import { DataViewContext } from './hooks/data_view_context'; +import { mockData } from './sample_data'; + +const AllAssetsLazy = lazy(() => import('./pages/all_assets')); + +const rows = [ + ...mockData, + ...mockData, + ...mockData, + ...mockData, + ...mockData, + ...mockData, + ...mockData, +] as typeof mockData; // Initializing react-query const queryClient = new QueryClient({ @@ -26,15 +41,37 @@ const queryClient = new QueryClient({ }, }); -export const AssetInventoryRoutes = () => ( - - - - - - - -); +export const AssetInventoryRoutes = () => { + const dataViewQuery = useDataView('asset-inventory-logs'); + + const dataViewContextValue = { + dataView: dataViewQuery.data!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + dataViewRefetch: dataViewQuery.refetch, + dataViewIsLoading: dataViewQuery.isLoading, + dataViewIsRefetching: dataViewQuery.isRefetching, + }; + + return ( + + + + + + }> + {}} + flyoutComponent={() => <>} + /> + + + + + + + ); +}; export const routes: SecuritySubPluginRoutes = [ { diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/sample_data.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/sample_data.ts new file mode 100644 index 0000000000000..8ade0b64fe9ff --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/sample_data.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { type DataTableRecord } from '@kbn/discover-utils/types'; + +export const mockData = [ + { + id: '1', + raw: {}, + flattened: { + 'asset.risk': 89, + 'asset.name': 'kube-scheduler-cspm-control', + 'asset.criticality': 'high_impact', + 'asset.source': 'cloud-sec-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '2', + raw: {}, + flattened: { + 'asset.risk': 88, + 'asset.name': 'elastic-agent-LK3r', + 'asset.criticality': 'low_impact', + 'asset.source': 'security-ci', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '3', + raw: {}, + flattened: { + 'asset.risk': 89, + 'asset.name': 'app-server-1', + 'asset.criticality': 'high_impact', + 'asset.source': 'sa-testing', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '4', + raw: {}, + flattened: { + 'asset.risk': 87, + 'asset.name': 'database-backup-control', + 'asset.criticality': 'high_impact', + 'asset.source': 'elastic-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '5', + raw: {}, + flattened: { + 'asset.risk': 69, + 'asset.name': 'elastic-agent-XyZ3', + 'asset.criticality': 'low_impact', + 'asset.source': 'elastic-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '6', + raw: {}, + flattened: { + 'asset.risk': 65, + 'asset.name': 'kube-controller-cspm-monitor', + 'asset.criticality': null, + 'asset.source': 'cloud-sec-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '7', + raw: {}, + flattened: { + 'asset.risk': 89, + 'asset.name': 'storage-service-AWS-EU-1', + 'asset.criticality': 'medium_impact', + 'asset.source': 'cloud-sec-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '8', + raw: {}, + flattened: { + 'asset.risk': 19, + 'asset.name': 'web-server-LB2', + 'asset.criticality': 'low_impact', + 'asset.source': 'cloud-sec-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, + { + id: '9', + raw: {}, + flattened: { + 'asset.risk': 85, + 'asset.name': 'DNS-controller-azure-sec', + 'asset.criticality': null, + 'asset.source': 'cloud-sec-dev', + '@timestamp': '2025-01-01T00:00:00.000Z', + }, + }, +] as DataTableRecord[];