Implement Asset Inventory Bar Chart (#210938)

## Summary

Closes https://github.com/elastic/kibana/issues/201711.

Implement "Top 10 Asset Types" bar chart. 
- The X-axis shows all assets grouped by category (`entity.category`
field), one category per bar
- Each bar shows stacked subgroups of assets by source (`entity.type`
field)
- The Y-axis shows the counts of assets

### Depends on

- https://github.com/elastic/kibana/pull/208417 so that the chart
renders data fetched dynamically. When it gets merged, this one will be
rebased and will only contain the last commit as changes.

### Screenshots

<details><summary>Loading state (animated spinner from <a
href="https://eui.elastic.co/#/display/loading#chart"
target="_blank">Elastic Charts</a>)</summary>
<img width="1378" alt="Screenshot 2025-02-25 at 18 14 39"
src="https://github.com/user-attachments/assets/553294e2-aaee-40c0-b1bb-de3e85f64d78"
/>
</details> 

<details><summary>Fetching state (animated progress bar)</summary>
<img width="1376" alt="Screenshot 2025-02-25 at 18 14 58"
src="https://github.com/user-attachments/assets/accdbc0e-40a2-4b30-9f4e-808a466be4d5"
/>
</details>

<details><summary>Chart with fetched data</summary>
<img width="1428" alt="Screenshot 2025-02-24 at 13 11 03"
src="https://github.com/user-attachments/assets/3c455bc8-5bdd-4ea2-a946-53e138ae081b"
/>
</details>

<details><summary>Chart with filtered, fetched data (by type:
"Identity")</summary>
<img width="1429" alt="Screenshot 2025-02-24 at 13 11 17"
src="https://github.com/user-attachments/assets/a1e75210-757e-42d1-b852-945de5f3f44b"
/>
</details>

<details><summary>Empty chart - no data</summary>
<img width="1258" alt="Screenshot 2025-02-13 at 09 47 08"
src="https://github.com/user-attachments/assets/c239a5a6-337e-41c9-a9a3-7cdc2c9b1e01"
/>
</details>

### Definition of done

- [x] Add a bar chart titled "Top 10 Asset Types" to the "Asset
Inventory" page.
- [x] Use the `@elastic/charts` library to implement the visualization.
- [x] Configure the chart with:
  - **X-axis:** Asset type categories
  - **Y-axis:** Count of assets
- **Legend:** A color-coded key linking each bar to a specific category.
- [x] Ensure the chart is responsive when resizing the screen and
adheres to the [visual
spec](https://www.figma.com/design/9zUqAhhglT1EGYG4LOl1X6/Asset-Management?node-id=2946-19648&t=FuD3BEY4FyxAKV38-4).
- [x] Integrate the chart so that it updates based on the filters
section and the Unified Header component.

### How to test

Follow the instructions from [this
PR](https://github.com/elastic/kibana/pull/208417) to prepare the local
env with data.

Alternatively, open the
`asset_inventory/components/top_assets_bar_chart.tsx` file and edit
yourself the `data` prop that we pass into `<BarSeries>` with mocked
data. The data must have the following shape:

```js
[
  { 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, },
];
```

### Checklist

- [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)

### Identify risks

No risks whatsoever.
This commit is contained in:
Alberto Blázquez 2025-02-27 09:01:00 +01:00 committed by GitHub
parent 0907d5d339
commit f0584d9b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 356 additions and 63 deletions

View file

@ -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 (
<EuiFlexItem grow={true}>
<FilterGroupLoading />

View file

@ -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 (
<div css={chartStyles}>
<EuiProgress size="xs" color="accent" style={{ opacity: isFetching ? 1 : 0 }} />
{isLoading ? (
<EuiFlexGroup
justifyContent="center"
alignItems="center"
css={{ height: '100%', width: '100%' }}
>
<EuiLoadingChart size="xl" />
</EuiFlexGroup>
) : (
<Chart title={chartTitle}>
<Settings baseTheme={baseTheme} showLegend={true} animateData={true} />
<Axis
id="X-axis"
position={Position.Bottom}
gridLine={{
visible: false,
}}
/>
<Axis
id="Y-axis"
position={Position.Left}
title={yAxisTitle}
maximumFractionDigits={0}
showOverlappingTicks={false}
gridLine={{
visible: false,
}}
/>
<BarSeries
id="grouped-categories"
xScaleType={ScaleType.Ordinal}
yScaleType={ScaleType.Linear}
xAccessor="category"
yAccessors={['count']}
yNice={true}
splitSeriesAccessors={['source']}
stackAccessors={['category']}
minBarHeight={1}
data={entities}
/>
</Chart>
)}
</div>
);
};

View file

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

View file

@ -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<estypes.SearchRequest>;
type TopAssetsResponse = IKibanaSearchResponse<
estypes.SearchResponse<AggregationResult, AssetAggs>
>;
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<TopAssetsRequest, TopAssetsResponse>({
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),
}
);
}

View file

@ -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: {

View file

@ -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 (
<I18nProvider>
@ -447,6 +453,13 @@ const AllAssets = ({
setUrlQuery({ filters: newFilters });
}}
/>
{dataView ? (
<TopAssetsBarChart
isLoading={isLoadingChartData}
isFetching={isFetchingChartData}
entities={!!chartData && chartData.length > 0 ? chartData : []}
/>
) : null}
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<div
data-test-subj={rest['data-test-subj']}
@ -455,7 +468,7 @@ const AllAssets = ({
height: computeDataTableRendering.wrapperHeight,
}}
>
<EuiProgress size="xs" color="accent" style={loadingStyle} />
<EuiProgress size="xs" color="accent" style={{ opacity: isFetching ? 1 : 0 }} />
{!dataView ? null : loadingState === DataLoadingState.loaded && totalHits === 0 ? (
<EmptyState onResetFilters={onResetFilters} />
) : (