Implement Asset Inventory data grid (#206115)

## Summary

Closes https://github.com/elastic/security-team/issues/11270.

### Screenshots

<details><summary>Current state</summary>
<img width="1486" alt="Screenshot 2025-01-15 at 17 28 42"
src="https://github.com/user-attachments/assets/1a39ae67-406c-464d-849b-0fba3380e982"
/>
</details> 

<details><summary>Current state + RiskBadge + Criticality + SearchBar
(implemented in separate PRs)</summary>
<img width="1752" alt="Screenshot 2025-01-13 at 16 34 10"
src="https://github.com/user-attachments/assets/bca30c71-dba3-4505-aba6-a3787ba7f6b1"
/>
</details>

### Definition of done

> [!NOTE]
>  For now it only works with static data until backend is ready

- [x] Implement DataGrid using the `<UnifiedDataTable>` 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, `<UnifiedDataTable>` 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`.

<details><summary>Data View Example</summary>
<img width="894" alt="Screenshot 2025-01-10 at 11 09 00"
src="https://github.com/user-attachments/assets/9a20f504-e602-4b67-a24e-0341f447878e"
/>
</details> 

### 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.
This commit is contained in:
Alberto Blázquez 2025-01-16 16:35:49 +01:00 committed by GitHub
parent 49f9724680
commit 5cc1315dd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1662 additions and 70 deletions

View file

@ -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<PropsWithChildren<unknown>> = ({ children }) => {
const styles = useStyles();
return (
<EuiFlexItem grow={false} className={styles.groupBySelector}>
{children}
</EuiFlexItem>
);
};
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 && (
<FieldsSelectorModal
columns={columns}
dataView={dataView}
closeModal={closeFieldsSelectorModal}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
onResetColumns={onResetColumns}
/>
)}
<EuiFlexItem grow={0}>
<span className="assetInventoryDataTableTotal">{`${getAbbreviatedNumber(
total
)} ${title}`}</span>
</EuiFlexItem>
<EuiFlexItem grow={0}>
<EuiButtonEmpty
className="assetInventoryDataTableFields"
iconType="tableOfContents"
onClick={openFieldsSelectorModal}
size="xs"
color="text"
data-test-subj={ASSET_INVENTORY_FIELDS_SELECTOR_OPEN_BUTTON}
>
<FormattedMessage
id="xpack.securitySolution.assetInventory.dataTable.fieldsButton"
defaultMessage="Fields"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{groupSelectorComponent && (
<GroupSelectorWrapper>{groupSelectorComponent}</GroupSelectorWrapper>
)}
</>
);
};

View file

@ -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 (
<I18nProvider>
<>
<EuiPageTemplate restrictWidth="1000px">
<EuiPageTemplate.Header>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.securitySolution.assetInventory.allAssets"
defaultMessage="All Assets"
/>
</h1>
</EuiTitle>
</EuiPageTemplate.Header>
<EuiPageTemplate.Section />
</EuiPageTemplate>
</>
</I18nProvider>
);
};
// we need to use default exports to import it via React.lazy
export default AssetInventoryApp; // eslint-disable-line import/no-default-export

View file

@ -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
css={css`
max-width: 734px;
&& > .euiEmptyPrompt__main {
gap: ${euiTheme.size.xl};
}
&& {
margin-top: ${euiTheme.size.xxxl}};
}
`}
data-test-subj={EMPTY_STATE_TEST_SUBJ}
icon={
<EuiImage
url={illustration}
alt={i18n.translate('xpack.securitySolution.assetInventory.emptyState.illustrationAlt', {
defaultMessage: 'No results',
})}
css={css`
width: 290px;
`}
/>
}
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.title"
defaultMessage="No results match your search criteria"
/>
</h2>
}
layout="horizontal"
color="plain"
body={
<>
<p>
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.description"
defaultMessage="Try modifying your search or filter set"
/>
</p>
</>
}
actions={[
<EuiButton color="primary" fill onClick={onResetFilters}>
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.resetFiltersButton"
defaultMessage="Reset filters"
/>
</EuiButton>,
<EuiLink href={docsUrl} target="_blank">
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.readDocsLink"
defaultMessage="Read the docs"
/>
</EuiLink>,
]}
/>
);
};

View file

@ -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 (
<EuiModal onClose={closeModal} data-test-subj={ASSET_INVENTORY_FIELDS_SELECTOR_MODAL}>
<EuiModalHeader>
<EuiModalHeaderTitle>{title}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<FieldsSelectorTable
title={title}
dataView={dataView}
columns={columns}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
onClick={onResetColumns}
data-test-subj={ASSET_INVENTORY_FIELDS_SELECTOR_RESET_BUTTON}
>
<FormattedMessage
id="xpack.securitySolution.assetInventory.dataTable.fieldsModalReset"
defaultMessage="Reset Fields"
/>
</EuiButtonEmpty>
<EuiButton
onClick={closeModal}
fill
data-test-subj={ASSET_INVENTORY_FIELDS_SELECTOR_CLOSE_BUTTON}
>
<FormattedMessage
id="xpack.securitySolution.assetInventory.dataTable.fieldsModalClose"
defaultMessage="Close"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -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<string | undefined>();
const [isFilterSelectedEnabled, setIsFilterSelectedEnabled] = useSessionStorage(
SESSION_STORAGE_FIELDS_MODAL_SHOW_SELECTED,
false
);
const [pagination, setPagination] = useState({ pageIndex: 0 });
const onTableChange = ({ page: { index } }: CriteriaWithPagination<Field>) => {
setPagination({ pageIndex: index });
};
const fields = useMemo<Field[]>(
() =>
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<typeof setTimeout>;
const onQueryChange: EuiSearchBarProps['onChange'] = ({ query }) => {
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
setSearchQuery(query?.text);
}, 300);
};
const tableColumns: Array<EuiBasicTableColumn<Field>> = [
{
field: 'action',
name: '',
width: ACTION_COLUMN_WIDTH,
sortable: false,
render: (_, { id }: Field) => (
<EuiCheckbox
checked={columns.includes(id)}
id={`cloud-security-fields-selector-item-${id}`}
data-test-subj={`cloud-security-fields-selector-item-${id}`}
onChange={(e) => {
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 (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText data-test-subj="assetInventory:dataTable:fieldsModal:fieldsShowing" size="xs">
<FormattedMessage
id="xpack.securitySolution.assetInventory.allAssets.fieldsModalFieldsShowing"
defaultMessage="Showing"
/>{' '}
<strong data-test-subj="assetInventory:dataTable:fieldsModal:fieldsCount">
{totalFields}
</strong>{' '}
<FormattedMessage
id="xpack.securitySolution.assetInventory.allAssets.fieldsModalFieldsCount"
defaultMessage="{totalFields, plural, one {field} other {fields}}"
values={{
totalFields,
}}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
panelPaddingSize="none"
anchorPosition="downRight"
isOpen={isPopoverOpen}
closePopover={closePopover}
button={
<EuiButtonEmpty
data-test-subj="viewSelectorButton"
size="xs"
iconType="arrowDown"
iconSide="right"
onClick={togglePopover}
>
{`${VIEW_LABEL}: ${isFilterSelectedEnabled ? VIEW_VALUE_SELECTED : VIEW_VALUE_ALL}`}
</EuiButtonEmpty>
}
>
<EuiContextMenuPanel
data-test-subj="viewSelectorMenu"
size="s"
items={[
<EuiContextMenuItem
data-test-subj="viewSelectorOption-all"
key="viewAll"
icon={isFilterSelectedEnabled ? 'empty' : 'check'}
onClick={() => {
onFilterSelectedChange(false);
closePopover();
}}
>
{`${VIEW_LABEL} ${VIEW_VALUE_ALL}`}
</EuiContextMenuItem>,
<EuiHorizontalRule key="separator" margin="none" />,
<EuiContextMenuItem
data-test-subj="viewSelectorOption-selected"
key="viewSelected"
icon={isFilterSelectedEnabled ? 'check' : 'empty'}
onClick={() => {
onFilterSelectedChange(true);
closePopover();
}}
>
{`${VIEW_LABEL} ${VIEW_VALUE_SELECTED}`}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [
closePopover,
fields.length,
isFilterSelectedEnabled,
isPopoverOpen,
onFilterSelectedChange,
togglePopover,
]);
return (
<EuiInMemoryTable
tableCaption={title}
items={fields}
columns={tableColumns}
search={search}
pagination={pagination}
sorting={defaultSorting}
error={error}
childrenBetween={tableHeader}
onTableChange={onTableChange}
/>
);
};

View file

@ -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<DataViewContextValue | undefined>(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;
};

View file

@ -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';

View file

@ -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<string, unknown>;
type SortOrder = [string, string];
export interface AssetInventoryDataTableResult {
setUrlQuery: (query: Record<string, unknown>) => void;
sort: SortOrder[];
filters: Filter[];
query: { bool: BoolQuery };
queryError?: Error;
pageIndex: number;
urlQuery: URLQuery;
setTableOptions: (options: CriteriaWithPagination<object>) => void;
handleUpdateQuery: (query: URLQuery) => void;
pageSize: number;
setPageSize: Dispatch<SetStateAction<number | undefined>>;
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<URLQuery>(defaultQuery);
const { urlQuery, setUrlQuery } = useUrlQuery<URLQuery>(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<object>) => {
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,
};
};

View file

@ -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;
};

View file

@ -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 };
};

View file

@ -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 = <T>(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]
);
};

View file

@ -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 = <T extends object>(getDefaultQuery: () => T) => {
const { push, replace } = useHistory();
const { search, key } = useLocation();
const urlQuery = useMemo(
() => ({ ...getDefaultQuery(), ...decodeQuery<T>(search) }),
[getDefaultQuery, search]
);
const setUrlQuery = useCallback(
(query: Partial<T>) =>
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,
};
};

View file

@ -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,
});

View file

@ -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,
};
};

View file

@ -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,
};
};

View file

@ -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<string, string> = {
'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<unknown>) | 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<UnifiedDataTableSettings>(
`${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<DataTableRecord | undefined>(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 = (
<AdditionalControls
total={rows.length}
dataView={dataView}
title={title}
columns={currentColumns}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
// groupSelectorComponent={groupSelectorComponent}
onResetColumns={onResetColumns}
/>
);
const externalControlColumns: EuiDataGridControlColumn[] = [
{
id: 'take-action',
width: 20,
headerCellRender: () => null,
rowCellRender: ({ rowIndex }) => (
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.securitySolution.assetInventory.flyout.moreActionsButton',
{
defaultMessage: 'More actions',
}
)}
iconType="boxesHorizontal"
color="primary"
isLoading={isLoading}
// onClick={() => 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 <EmptyState onResetFilters={onResetFilters} />;
}
return (
<I18nProvider>
<EuiPageTemplate.Section>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.securitySolution.assetInventory.allAssets"
defaultMessage="All Assets"
/>
</h1>
</EuiTitle>
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<div
data-test-subj={rest['data-test-subj']}
className={styles.gridContainer}
style={{
height: computeDataTableRendering.wrapperHeight,
}}
>
<EuiProgress size="xs" color="accent" style={loadingStyle} />
{!dataView ? null : (
<UnifiedDataTable
key={computeDataTableRendering.mode}
className={styles.gridStyle}
ariaLabelledBy={title}
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
loadingState={loadingState}
onFilter={onAddFilter as DocViewFilterFn}
onResize={onResize}
onSetColumns={onSetColumns}
onSort={onSort}
rows={rows}
sampleSizeState={MAX_ASSETS_TO_LOAD}
setExpandedDoc={setExpandedDoc}
renderDocumentView={renderDocumentView}
sort={sort}
rowsPerPageState={pageSize}
totalHits={rows.length}
services={services}
onUpdateRowsPerPage={onChangeItemsPerPage}
rowHeightState={0}
showMultiFields={uiSettings.get(SHOW_MULTIFIELDS)}
showTimeCol={false}
settings={settings}
onFetchMoreRecords={loadMore}
externalControlColumns={externalControlColumns}
externalCustomRenderers={externalCustomRenderers}
externalAdditionalControls={externalAdditionalControls}
gridStyleOverride={gridStyle}
rowLineHeightOverride="24px"
dataGridDensityState={DataGridDensity.EXPANDED}
showFullScreenButton
// showKeyboardShortcuts
/>
)}
</div>
</CellActionsProvider>
</EuiPageTemplate.Section>
</I18nProvider>
);
};
// we need to use default exports to import it via React.lazy
export default AllAssets; // eslint-disable-line import/no-default-export

View file

@ -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 (
<SecuritySolutionPageWrapper noPadding>
<Suspense fallback={<EuiLoadingSpinner />}>
<AssetInventoryLazy />
</Suspense>
</SecuritySolutionPageWrapper>
);
});
AssetInventoryContainer.displayName = 'AssetInventoryContainer';

View file

@ -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 = () => (
<QueryClientProvider client={queryClient}>
<PluginTemplateWrapper>
<SecurityRoutePageWrapper pageName={SecurityPageName.assetInventory}>
<AssetInventoryContainer />
</SecurityRoutePageWrapper>
</PluginTemplateWrapper>
</QueryClientProvider>
);
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 (
<QueryClientProvider client={queryClient}>
<PluginTemplateWrapper>
<SecurityRoutePageWrapper pageName={SecurityPageName.assetInventory}>
<DataViewContext.Provider value={dataViewContextValue}>
<SecuritySolutionPageWrapper noPadding>
<Suspense fallback={<EuiLoadingSpinner />}>
<AllAssetsLazy
rows={rows}
isLoading={false}
loadMore={() => {}}
flyoutComponent={() => <></>}
/>
</Suspense>
</SecuritySolutionPageWrapper>
</DataViewContext.Provider>
</SecurityRoutePageWrapper>
</PluginTemplateWrapper>
</QueryClientProvider>
);
};
export const routes: SecuritySubPluginRoutes = [
{

View file

@ -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[];