mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
0907d5d339
commit
f0584d9b90
6 changed files with 356 additions and 63 deletions
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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),
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue