diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/filters/filters.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/filters/filters.tsx index e9c93ba31be49..ba91be8881226 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/filters/filters.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/filters/filters.tsx @@ -50,7 +50,7 @@ export interface FiltersProps { } export const Filters = ({ onFiltersChange }: FiltersProps) => { - const { dataView, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext(); + const { dataView, dataViewIsLoading } = useDataViewContext(); const spaceId = useSpaceId(); const dataViewSpec = useMemo( @@ -72,7 +72,7 @@ export const Filters = ({ onFiltersChange }: FiltersProps) => { return null; } - if (dataViewIsLoading || dataViewIsRefetching) { + if (dataViewIsLoading) { return ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/top_assets_bar_chart.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/top_assets_bar_chart.tsx new file mode 100644 index 0000000000000..1cad7e117079a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/top_assets_bar_chart.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiProgress, EuiFlexGroup, EuiLoadingChart } from '@elastic/eui'; +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; +import { useElasticChartsTheme } from '@kbn/charts-theme'; +import { i18n } from '@kbn/i18n'; +import type { AggregationResult } from '../hooks/use_fetch_chart_data'; + +const chartTitle = i18n.translate( + 'xpack.securitySolution.assetInventory.topAssetsBarChart.chartTitle', + { + defaultMessage: 'Top 10 Asset Types', + } +); + +const yAxisTitle = i18n.translate( + 'xpack.securitySolution.assetInventory.topAssetsBarChart.yAxisTitle', + { + defaultMessage: 'Count of Assets', + } +); + +const chartStyles = { height: '260px' }; + +export interface TopAssetsBarChartProps { + isLoading: boolean; + isFetching: boolean; + entities: AggregationResult[]; +} + +export const TopAssetsBarChart = ({ isLoading, isFetching, entities }: TopAssetsBarChartProps) => { + const baseTheme = useElasticChartsTheme(); + return ( +
+ + {isLoading ? ( + + + + ) : ( + + + + + + + )} +
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/fetch_utils.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/fetch_utils.ts new file mode 100644 index 0000000000000..3286362305ee5 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/fetch_utils.ts @@ -0,0 +1,57 @@ +/* + * 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 { RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; + +export const getRuntimeMappingsFromSort = (fields: string[], sort: string[][]) => { + return sort + .filter(([field]) => fields.includes(field)) + .reduce((acc, [field]) => { + const type: RuntimePrimitiveTypes = 'keyword'; + + return { + ...acc, + [field]: { + type, + }, + }; + }, {}); +}; + +export const getMultiFieldsSort = (sort: string[][]) => { + return sort.map(([id, direction]) => { + return { + ...getSortField({ field: id, direction }), + }; + }); +}; + +/** + * By default, ES will sort keyword fields in case-sensitive format, the + * following fields are required to have a case-insensitive sorting. + */ +const fieldsRequiredSortingByPainlessScript = ['entity.name']; // TODO TBD + +/** + * Generates Painless sorting if the given field is matched or returns default sorting + * This painless script will sort the field in case-insensitive manner + */ +const getSortField = ({ field, direction }: { field: string; direction: string }) => { + if (fieldsRequiredSortingByPainlessScript.includes(field)) { + return { + _script: { + type: 'string', + order: direction, + script: { + source: `doc["${field}"].value.toLowerCase()`, + lang: 'painless', + }, + }, + }; + } + return { [field]: direction }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_chart_data.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_chart_data.ts new file mode 100644 index 0000000000000..130b9821854e8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_chart_data.ts @@ -0,0 +1,184 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { lastValueFrom } from 'rxjs'; +import { i18n } from '@kbn/i18n'; +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; +import { showErrorToast } from '@kbn/cloud-security-posture'; +import type { IKibanaSearchResponse, IKibanaSearchRequest } from '@kbn/search-types'; +import type { FindingsBaseEsQuery } from '@kbn/cloud-security-posture'; +import { useKibana } from '../../common/lib/kibana'; +import { ASSET_INVENTORY_INDEX_PATTERN } from '../constants'; +import { getMultiFieldsSort } from './fetch_utils'; + +interface UseTopAssetsOptions extends FindingsBaseEsQuery { + sort: string[][]; + enabled: boolean; +} + +const getTopAssetsQuery = ({ query, sort }: UseTopAssetsOptions) => ({ + size: 0, + index: ASSET_INVENTORY_INDEX_PATTERN, + aggs: { + entityCategory: { + terms: { + field: 'entity.category', + order: { + entityId: 'desc', + }, + size: 10, + }, + aggs: { + entityType: { + terms: { + field: 'entity.type', + order: { + entityId: 'desc', + }, + size: 10, + }, + aggs: { + entityId: { + value_count: { + field: 'entity.id', + }, + }, + }, + }, + entityId: { + value_count: { + field: 'entity.id', + }, + }, + }, + }, + }, + query: { + ...query, + bool: { + ...query?.bool, + filter: [...(query?.bool?.filter ?? [])], + should: [...(query?.bool?.should ?? [])], + must: [...(query?.bool?.must ?? [])], + must_not: [...(query?.bool?.must_not ?? [])], + }, + }, + sort: getMultiFieldsSort(sort), + ignore_unavailable: true, +}); + +export interface AggregationResult { + category: string; + source: string; + count: number; +} + +interface TypeBucket { + key: string; + doc_count: number; + entityId: { + value: number; + }; +} + +interface CategoryBucket { + key: string; + doc_count: number; + entityId: { + value: number; + }; + entityType: { + buckets: TypeBucket[]; + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + }; + doc_count_error_upper_bound: number; + sum_other_doc_count: number; +} + +interface AssetAggs { + entityCategory: { + buckets: CategoryBucket[]; + }; +} + +const tooltipOtherLabel = i18n.translate( + 'xpack.securitySolution.assetInventory.chart.tooltip.otherLabel', + { + defaultMessage: 'Other', + } +); + +// Example output: +// +// [ +// { category: 'cloud-compute', source: 'gcp-compute', count: 500, }, +// { category: 'cloud-compute', source: 'aws-security', count: 300, }, +// { category: 'cloud-storage', source: 'gcp-compute', count: 221, }, +// { category: 'cloud-storage', source: 'aws-security', count: 117, }, +// ]; +function transformAggregation(agg: AssetAggs) { + const result: AggregationResult[] = []; + + for (const categoryBucket of agg.entityCategory.buckets) { + const typeBucket = categoryBucket.entityType; + + for (const sourceBucket of typeBucket.buckets) { + result.push({ + category: categoryBucket.key, + source: sourceBucket.key, + count: sourceBucket.doc_count, + }); + } + + if (typeBucket.sum_other_doc_count > 0) { + result.push({ + category: categoryBucket.key, + source: `${categoryBucket.key} - ${tooltipOtherLabel}`, + count: typeBucket.sum_other_doc_count, + }); + } + } + + return result; +} + +type TopAssetsRequest = IKibanaSearchRequest; +type TopAssetsResponse = IKibanaSearchResponse< + estypes.SearchResponse +>; + +export function useFetchChartData(options: UseTopAssetsOptions) { + const { + data, + notifications: { toasts }, + } = useKibana().services; + return useQuery( + ['asset_inventory_top_assets_chart', { params: options }], + async () => { + const { + rawResponse: { aggregations }, + } = await lastValueFrom( + data.search.search({ + params: getTopAssetsQuery(options) as TopAssetsRequest['params'], + }) + ); + + if (!aggregations) { + throw new Error('expected aggregations to be defined'); + } + + return transformAggregation(aggregations); + }, + { + enabled: options.enabled, + keepPreviousData: true, + onError: (err: Error) => showErrorToast(toasts, err), + } + ); +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_data.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_data.ts index b744b2cf4973e..08edb6aaaffe4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_fetch_data.ts @@ -11,12 +11,12 @@ import { number } from 'io-ts'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import { buildDataTableRecord } from '@kbn/discover-utils'; import type { EsHitRecord } from '@kbn/discover-utils/types'; -import type { RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; import { showErrorToast } from '@kbn/cloud-security-posture'; import type { IKibanaSearchResponse, IKibanaSearchRequest } from '@kbn/search-types'; import type { FindingsBaseEsQuery } from '@kbn/cloud-security-posture'; import { useKibana } from '../../common/lib/kibana'; import { MAX_ASSETS_TO_LOAD, ASSET_INVENTORY_INDEX_PATTERN } from '../constants'; +import { getRuntimeMappingsFromSort, getMultiFieldsSort } from './fetch_utils'; interface UseAssetsOptions extends FindingsBaseEsQuery { sort: string[][]; @@ -26,60 +26,14 @@ interface UseAssetsOptions extends FindingsBaseEsQuery { const ASSET_INVENTORY_TABLE_RUNTIME_MAPPING_FIELDS: string[] = ['entity.id', 'entity.name']; -const getRuntimeMappingsFromSort = (sort: string[][]) => { - return sort - .filter(([field]) => ASSET_INVENTORY_TABLE_RUNTIME_MAPPING_FIELDS.includes(field)) - .reduce((acc, [field]) => { - const type: RuntimePrimitiveTypes = 'keyword'; - - return { - ...acc, - [field]: { - type, - }, - }; - }, {}); -}; - -const getMultiFieldsSort = (sort: string[][]) => { - return sort.map(([id, direction]) => { - return { - ...getSortField({ field: id, direction }), - }; - }); -}; - -/** - * By default, ES will sort keyword fields in case-sensitive format, the - * following fields are required to have a case-insensitive sorting. - */ -const fieldsRequiredSortingByPainlessScript = ['entity.name']; // TODO TBD - -/** - * Generates Painless sorting if the given field is matched or returns default sorting - * This painless script will sort the field in case-insensitive manner - */ -const getSortField = ({ field, direction }: { field: string; direction: string }) => { - if (fieldsRequiredSortingByPainlessScript.includes(field)) { - return { - _script: { - type: 'string', - order: direction, - script: { - source: `doc["${field}"].value.toLowerCase()`, - lang: 'painless', - }, - }, - }; - } - return { [field]: direction }; -}; - const getAssetsQuery = ({ query, sort }: UseAssetsOptions, pageParam: unknown) => { return { index: ASSET_INVENTORY_INDEX_PATTERN, sort: getMultiFieldsSort(sort), - runtime_mappings: getRuntimeMappingsFromSort(sort), + runtime_mappings: getRuntimeMappingsFromSort( + ASSET_INVENTORY_TABLE_RUNTIME_MAPPING_FIELDS, + sort + ), size: MAX_ASSETS_TO_LOAD, ignore_unavailable: true, query: { 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 index 7d29e83a2d83f..d567af8b7dd36 100644 --- 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 @@ -51,6 +51,7 @@ import { AssetInventorySearchBar } from '../components/search_bar'; import { RiskBadge } from '../components/risk_badge'; import { Filters } from '../components/filters/filters'; import { EmptyState } from '../components/empty_state'; +import { TopAssetsBarChart } from '../components/top_assets_bar_chart'; import { useDataViewContext } from '../hooks/data_view_context'; import { useStyles } from '../hooks/use_styles'; @@ -60,6 +61,7 @@ import { type URLQuery, } from '../hooks/use_asset_inventory_data_table'; import { useFetchData } from '../hooks/use_fetch_data'; +import { useFetchChartData } from '../hooks/use_fetch_chart_data'; import { DEFAULT_VISIBLE_ROWS_PER_PAGE, MAX_ASSETS_TO_LOAD } from '../constants'; const gridStyle: EuiDataGridStyle = { @@ -218,6 +220,17 @@ const AllAssets = ({ pageSize: DEFAULT_VISIBLE_ROWS_PER_PAGE, }); + const { + data: chartData, + // error: fetchChartDataError, + isFetching: isFetchingChartData, + isLoading: isLoadingChartData, + } = useFetchChartData({ + query, + sort, + enabled: !queryError, + }); + const rows = getRowsFromPages(rowsData?.pages); const totalHits = rowsData?.pages[0].total || 0; @@ -253,7 +266,7 @@ const AllAssets = ({ }; }, [persistedSettings]); - const { dataView, dataViewIsLoading, dataViewIsRefetching } = useDataViewContext(); + const { dataView } = useDataViewContext(); const { uiActions, @@ -398,14 +411,7 @@ const AllAssets = ({ }, ]; - const loadingStyle = { - opacity: isLoading ? 1 : 0, - }; - - const loadingState = - isLoading || isFetching || dataViewIsLoading || dataViewIsRefetching || !dataView - ? DataLoadingState.loading - : DataLoadingState.loaded; + const loadingState = isLoading || !dataView ? DataLoadingState.loading : DataLoadingState.loaded; return ( @@ -447,6 +453,13 @@ const AllAssets = ({ setUrlQuery({ filters: newFilters }); }} /> + {dataView ? ( + 0 ? chartData : []} + /> + ) : null}
- + {!dataView ? null : loadingState === DataLoadingState.loaded && totalHits === 0 ? ( ) : (