[Inventory][ECO] Entities table (#193272)

Real data:
<img width="1237" alt="Screenshot 2024-09-18 at 14 23 17"
src="https://github.com/user-attachments/assets/ecc496aa-1c43-4c3c-9ac8-d6e4e6cb8aad">

Storybook:
<img width="1256" alt="Screenshot 2024-09-18 at 14 23 22"
src="https://github.com/user-attachments/assets/03d9f940-7b3f-4aea-9221-42b1c07119d1">

Tooltips:
<img width="1250" alt="Screenshot 2024-09-18 at 13 49 19"
src="https://github.com/user-attachments/assets/dc99b4cc-4eba-4815-8892-8e3fe7a041bb">


- Use ESQL to fetch the top 500 entities sorted by last seen property.
- Display 20 entities per page.
- Sorting is handles by the server and saved on the URL
- Current page is saved on the URL
- Filter entities types `service`, `host` or `container`
- Filter only entities from the built in definition
- LIMITATION: The EuiGrid doesn't have an embedded loading state, for
now, I'm switching the entire view to display a loading spinner while
data is being fetched.
- PLUS: Storybook created with mock data.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2024-09-18 16:06:13 +01:00 committed by GitHub
parent 5040e3580c
commit e3f3c68e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 3501 additions and 77 deletions

View file

@ -6,8 +6,9 @@
*/
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import type { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import type { ESQLSearchResponse, ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import { withSpan } from '@kbn/apm-utils';
import type { EsqlQueryRequest } from '@elastic/elasticsearch/lib/api/types';
type SearchRequest = ESSearchRequest & {
index: string | string[];
@ -24,6 +25,7 @@ export interface ObservabilityElasticsearchClient {
operationName: string,
parameters: TSearchRequest
): Promise<InferSearchResponseOf<TDocument, TSearchRequest>>;
esql(operationName: string, parameters: EsqlQueryRequest): Promise<ESQLSearchResponse>;
client: ElasticsearchClient;
}
@ -38,6 +40,26 @@ export function createObservabilityEsClient({
}): ObservabilityElasticsearchClient {
return {
client,
esql(operationName: string, parameters: EsqlQueryRequest) {
logger.trace(() => `Request (${operationName}):\n${JSON.stringify(parameters, null, 2)}`);
return withSpan({ name: operationName, labels: { plugin } }, () => {
return client.esql.query(
{ ...parameters },
{
querystring: {
drop_null_columns: true,
},
}
);
})
.then((response) => {
logger.trace(() => `Response (${operationName}):\n${JSON.stringify(response, null, 2)}`);
return response as unknown as ESQLSearchResponse;
})
.catch((error) => {
throw error;
});
},
search<TDocument = unknown, TSearchRequest extends SearchRequest = SearchRequest>(
operationName: string,
parameters: SearchRequest

View file

@ -0,0 +1,20 @@
/*
* 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 { ESQLSearchResponse } from '@kbn/es-types';
export function esqlResultToPlainObjects<T extends Record<string, any>>(
result: ESQLSearchResponse
): T[] {
return result.values.map((row) => {
return row.reduce<Record<string, unknown>>((acc, value, index) => {
const column = result.columns[index];
acc[column.name] = value;
return acc;
}, {});
}) as T[];
}

View file

@ -4,23 +4,14 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as t from 'io-ts';
export interface LatestEntity {
agent: {
name: string[];
};
data_stream: {
type: string[];
};
cloud: {
availability_zone: string[];
};
entity: {
firstSeenTimestamp: string;
lastSeenTimestamp: string;
type: string;
displayName: string;
id: string;
identityFields: string[];
};
}
export const entityTypeRt = t.union([
t.literal('service'),
t.literal('host'),
t.literal('container'),
]);
export type EntityType = t.TypeOf<typeof entityTypeRt>;
export const MAX_NUMBER_OF_ENTITIES = 500;

View file

@ -0,0 +1,12 @@
/*
* 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 const ENTITY_LAST_SEEN = 'entity.lastSeenTimestamp';
export const ENTITY_ID = 'entity.id';
export const ENTITY_TYPE = 'entity.type';
export const ENTITY_DISPLAY_NAME = 'entity.displayName';
export const ENTITY_DEFINITION_ID = 'entity.definitionId';

View file

@ -0,0 +1,65 @@
/*
* 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 { EuiDataGridSorting } from '@elastic/eui';
import { Meta, Story } from '@storybook/react';
import React, { useMemo, useState } from 'react';
import { orderBy } from 'lodash';
import { EntitiesGrid } from '.';
import { ENTITY_LAST_SEEN } from '../../../common/es_fields/entities';
import { entitiesMock } from './mock/entities_mock';
const stories: Meta<{}> = {
title: 'app/inventory/entities_grid',
component: EntitiesGrid,
};
export default stories;
export const Example: Story<{}> = () => {
const [pageIndex, setPageIndex] = useState(0);
const [sort, setSort] = useState<EuiDataGridSorting['columns'][0]>({
id: ENTITY_LAST_SEEN,
direction: 'desc',
});
const sortedItems = useMemo(
() => orderBy(entitiesMock, sort.id, sort.direction),
[sort.direction, sort.id]
);
return (
<EntitiesGrid
entities={sortedItems}
loading={false}
sortDirection={sort.direction}
sortField={sort.id}
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
/>
);
};
export const EmptyGridExample: Story<{}> = () => {
const [pageIndex, setPageIndex] = useState(0);
const [sort, setSort] = useState<EuiDataGridSorting['columns'][0]>({
id: ENTITY_LAST_SEEN,
direction: 'desc',
});
return (
<EntitiesGrid
entities={[]}
loading={false}
sortDirection={sort.direction}
sortField={sort.id}
onChangePage={setPageIndex}
onChangeSort={setSort}
pageIndex={pageIndex}
/>
);
};

View file

@ -5,53 +5,186 @@
* 2.0.
*/
import {
EuiBadge,
EuiButtonIcon,
EuiDataGrid,
EuiDataGridCellValueElementProps,
EuiDataGridColumn,
EuiDataGridSorting,
EuiLink,
EuiLoadingSpinner,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useAbortableAsync } from '@kbn/observability-utils/hooks/use_abortable_async';
import React, { useState } from 'react';
import { useKibana } from '../../hooks/use_kibana';
import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react';
import { last } from 'lodash';
import React, { useCallback, useState } from 'react';
import {
ENTITY_DISPLAY_NAME,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '../../../common/es_fields/entities';
import { APIReturnType } from '../../api';
type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>;
type EntityColumnIds = typeof ENTITY_DISPLAY_NAME | typeof ENTITY_LAST_SEEN | typeof ENTITY_TYPE;
const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => (
<>
<span>{title}</span>
<EuiToolTip content={tooltipContent}>
<EuiButtonIcon
data-test-subj="inventoryCustomHeaderCellButton"
iconType="questionInCircle"
aria-label={tooltipContent}
color="primary"
/>
</EuiToolTip>
</>
);
const entityNameLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel', {
defaultMessage: 'Entity name',
});
const entityTypeLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeLabel', {
defaultMessage: 'Type',
});
const entityLastSeenLabel = i18n.translate(
'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenLabel',
{
defaultMessage: 'Last seen',
}
);
const columns: EuiDataGridColumn[] = [
{
id: 'entityName',
displayAsText: 'Entity name',
id: ENTITY_DISPLAY_NAME,
// keep it for accessibility purposes
displayAsText: entityNameLabel,
display: (
<CustomHeaderCell
title={entityNameLabel}
tooltipContent="Name of the entity (entity.displayName)"
/>
),
isSortable: true,
},
{
id: 'entityType',
displayAsText: 'Type',
id: ENTITY_TYPE,
// keep it for accessibility purposes
displayAsText: entityTypeLabel,
display: (
<CustomHeaderCell title={entityTypeLabel} tooltipContent="Type of entity (entity.type)" />
),
isSortable: true,
},
{
id: ENTITY_LAST_SEEN,
// keep it for accessibility purposes
displayAsText: entityLastSeenLabel,
display: (
<CustomHeaderCell
title={entityLastSeenLabel}
tooltipContent="Timestamp of last received data for entity (entity.lastSeenTimestamp)"
/>
),
defaultSortDirection: 'desc',
isSortable: true,
schema: 'datetime',
},
];
export function EntitiesGrid() {
const {
services: { inventoryAPIClient },
} = useKibana();
interface Props {
loading: boolean;
entities: InventoryEntitiesAPIReturnType['entities'];
sortDirection: 'asc' | 'desc';
sortField: string;
pageIndex: number;
onChangeSort: (sorting: EuiDataGridSorting['columns'][0]) => void;
onChangePage: (nextPage: number) => void;
}
const PAGE_SIZE = 20;
export function EntitiesGrid({
entities,
loading,
sortDirection,
sortField,
pageIndex,
onChangePage,
onChangeSort,
}: Props) {
const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id));
const { value = { entities: [] }, loading } = useAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
signal,
});
const onSort: EuiDataGridSorting['onSort'] = useCallback(
(newSortingColumns) => {
const lastItem = last(newSortingColumns);
if (lastItem) {
onChangeSort(lastItem);
}
},
[inventoryAPIClient]
[onChangeSort]
);
const renderCellValue = useCallback(
({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const entity = entities[rowIndex];
if (entity === undefined) {
return null;
}
const columnEntityTableId = columnId as EntityColumnIds;
switch (columnEntityTableId) {
case ENTITY_TYPE:
return <EuiBadge color="hollow">{entity[columnEntityTableId]}</EuiBadge>;
case ENTITY_LAST_SEEN:
return (
<FormattedMessage
id="xpack.inventory.entitiesGrid.euiDataGrid.lastSeen"
defaultMessage="{date} @ {time}"
values={{
date: (
<FormattedDate
value={entity[columnEntityTableId]}
month="short"
day="numeric"
year="numeric"
/>
),
time: (
<FormattedTime
value={entity[columnEntityTableId]}
hour12={false}
hour="2-digit"
minute="2-digit"
second="2-digit"
/>
),
}}
/>
);
case ENTITY_DISPLAY_NAME:
return (
// TODO: link to the appropriate page based on entity type https://github.com/elastic/kibana/issues/192676
<EuiLink data-test-subj="inventoryCellValueLink" className="eui-textTruncate">
{entity[columnEntityTableId]}
</EuiLink>
);
default:
return entity[columnId as EntityColumnIds] || '';
}
},
[entities]
);
if (loading) {
return <EuiLoadingSpinner size="s" />;
}
function CellValue({ rowIndex, columnId, setCellProps }: EuiDataGridCellValueElementProps) {
const data = value.entities[rowIndex];
if (data === undefined) {
return null;
}
return <>{data.entity.displayName}</>;
}
const currentPage = pageIndex + 1;
return (
<EuiDataGrid
@ -61,8 +194,50 @@ export function EntitiesGrid() {
)}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
rowCount={value.entities.length}
renderCellValue={CellValue}
rowCount={entities.length}
renderCellValue={renderCellValue}
gridStyle={{ border: 'horizontal', header: 'shade' }}
toolbarVisibility={{
showColumnSelector: false,
showSortSelector: false,
additionalControls: {
left: {
prepend: (
<EuiText size="s">
<FormattedMessage
id="xpack.inventory.entitiesGrid.euiDataGrid.headerLeft"
defaultMessage="Showing {currentItems} of {total} {boldEntites}"
values={{
currentItems: (
<strong>
{Math.min(entities.length, pageIndex * PAGE_SIZE + 1)}-
{Math.min(entities.length, PAGE_SIZE * currentPage)}
</strong>
),
total: entities.length,
boldEntites: (
<strong>
{i18n.translate(
'xpack.inventory.entitiesGrid.euiDataGrid.headerLeft.entites',
{ defaultMessage: 'Entities' }
)}
</strong>
),
}}
/>
</EuiText>
),
},
},
}}
sorting={{ columns: [{ id: sortField, direction: sortDirection }], onSort }}
pagination={{
pageIndex,
pageSize: PAGE_SIZE,
onChangeItemsPerPage: () => {},
onChangePage,
pageSizeOptions: [],
}}
/>
);
}

View file

@ -5,12 +5,63 @@
* 2.0.
*/
import React from 'react';
import { useAbortableAsync } from '@kbn/observability-utils/hooks/use_abortable_async';
import { EuiDataGridSorting } from '@elastic/eui';
import { EntitiesGrid } from '../../components/entities_grid';
import { useKibana } from '../../hooks/use_kibana';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useInventoryRouter } from '../../hooks/use_inventory_router';
export function InventoryPage() {
const {
services: { inventoryAPIClient },
} = useKibana();
const { query } = useInventoryParams('/');
const { sortDirection, sortField, pageIndex } = query;
const inventoryRoute = useInventoryRouter();
const { value = { entities: [] }, loading } = useAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/entities', {
params: {
query: {
sortDirection,
sortField,
},
},
signal,
});
},
[inventoryAPIClient, sortDirection, sortField]
);
function handlePageChange(nextPage: number) {
inventoryRoute.push('/', {
path: {},
query: { ...query, pageIndex: nextPage },
});
}
function handleSortChange(sorting: EuiDataGridSorting['columns'][0]) {
inventoryRoute.push('/', {
path: {},
query: {
...query,
sortField: sorting.id,
sortDirection: sorting.direction,
},
});
}
return (
<div>
<EntitiesGrid />
</div>
<EntitiesGrid
entities={value.entities}
loading={loading}
sortDirection={sortDirection}
sortField={sortField}
onChangePage={handlePageChange}
onChangeSort={handleSortChange}
pageIndex={pageIndex}
/>
);
}

View file

@ -7,8 +7,10 @@
import * as t from 'io-ts';
import { createRouter, Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import { toNumberRt } from '@kbn/io-ts-utils';
import { InventoryPageTemplate } from '../components/inventory_page_template';
import { InventoryPage } from '../pages/inventory_page';
import { ENTITY_LAST_SEEN } from '../../common/es_fields/entities';
/**
* The array of route definitions to be used when the application
@ -21,6 +23,20 @@ const inventoryRoutes = {
<Outlet />
</InventoryPageTemplate>
),
params: t.type({
query: t.type({
sortField: t.string,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
pageIndex: toNumberRt,
}),
}),
defaults: {
query: {
sortField: ENTITY_LAST_SEEN,
sortDirection: 'desc',
pageIndex: '0',
},
},
children: {
'/{type}': {
element: <></>,

View file

@ -5,23 +5,63 @@
* 2.0.
*/
import { LatestEntity } from '../../../common/entities';
import { EntitiesESClient } from '../../lib/create_es_client/create_entities_es_client';
import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema';
import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import { MAX_NUMBER_OF_ENTITIES, type EntityType } from '../../../common/entities';
import {
ENTITY_DEFINITION_ID,
ENTITY_DISPLAY_NAME,
ENTITY_ID,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '../../../common/es_fields/entities';
const MAX_NUMBER_OF_ENTITIES = 500;
const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({
type: '*',
dataset: ENTITY_LATEST,
});
const BUILTIN_SERVICES_FROM_ECS_DATA = 'builtin_services_from_ecs_data';
const BUILTIN_HOSTS_FROM_ECS_DATA = 'builtin_hosts_from_ecs_data';
const BUILTIN_CONTAINERS_FROM_ECS_DATA = 'builtin_containers_from_ecs_data';
export interface LatestEntity {
[ENTITY_LAST_SEEN]: string;
[ENTITY_TYPE]: string;
[ENTITY_DISPLAY_NAME]: string;
[ENTITY_ID]: string;
}
const DEFAULT_ENTITY_TYPES = ['service', 'host', 'container'];
export async function getLatestEntities({
entitiesESClient,
inventoryEsClient,
sortDirection,
sortField,
entityTypes,
}: {
entitiesESClient: EntitiesESClient;
inventoryEsClient: ObservabilityElasticsearchClient;
sortDirection: 'asc' | 'desc';
sortField: string;
entityTypes?: EntityType[];
}) {
const response = (
await entitiesESClient.searchLatest<LatestEntity>('get_latest_entities', {
body: {
size: MAX_NUMBER_OF_ENTITIES,
},
})
).hits.hits.map((hit) => hit._source);
const entityTypesFilter = entityTypes?.length ? entityTypes : DEFAULT_ENTITY_TYPES;
const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', {
query: `FROM ${ENTITIES_LATEST_ALIAS}
| WHERE ${ENTITY_TYPE} IN (${entityTypesFilter.map((entityType) => `"${entityType}"`).join()})
| WHERE ${ENTITY_DEFINITION_ID} IN (${[
BUILTIN_SERVICES_FROM_ECS_DATA,
BUILTIN_HOSTS_FROM_ECS_DATA,
BUILTIN_CONTAINERS_FROM_ECS_DATA,
]
.map((buildin) => `"${buildin}"`)
.join()})
| SORT ${sortField} ${sortDirection}
| LIMIT ${MAX_NUMBER_OF_ENTITIES}
| KEEP ${ENTITY_LAST_SEEN}, ${ENTITY_TYPE}, ${ENTITY_DISPLAY_NAME}, ${ENTITY_ID}
`,
});
return response;
return esqlResultToPlainObjects<LatestEntity>(latestEntitiesEsqlResponse);
}

View file

@ -4,23 +4,46 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { jsonRt } from '@kbn/io-ts-utils';
import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import * as t from 'io-ts';
import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants';
import { entityTypeRt } from '../../../common/entities';
import { createInventoryServerRoute } from '../create_inventory_server_route';
import { createEntitiesESClient } from '../../lib/create_es_client/create_entities_es_client';
import { getLatestEntities } from './get_latest_entities';
export const listLatestEntitiesRoute = createInventoryServerRoute({
endpoint: 'GET /internal/inventory/entities',
params: t.type({
query: t.intersection([
t.type({
sortField: t.string,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
entityTypes: jsonRt.pipe(t.array(entityTypeRt)),
}),
]),
}),
options: {
tags: ['access:inventory'],
},
handler: async ({ plugins, request, context }) => {
handler: async ({ params, context, logger }) => {
const coreContext = await context.core;
const entitiesESClient = createEntitiesESClient({
esClient: coreContext.elasticsearch.client.asCurrentUser,
request,
const inventoryEsClient = createObservabilityEsClient({
client: coreContext.elasticsearch.client.asCurrentUser,
logger,
plugin: `@kbn/${INVENTORY_APP_ID}-plugin`,
});
const latestEntities = await getLatestEntities({ entitiesESClient });
const { sortDirection, sortField, entityTypes } = params.query;
const latestEntities = await getLatestEntities({
inventoryEsClient,
sortDirection,
sortField,
entityTypes,
});
return { entities: latestEntities };
},

View file

@ -1,7 +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.
*/
// export { withApmSpan } from '@kbn/apm-data-access-plugin/server/utils';

View file

@ -35,6 +35,8 @@
"@kbn/server-route-repository-client",
"@kbn/react-kibana-context-render",
"@kbn/es-types",
"@kbn/entities-schema"
"@kbn/entities-schema",
"@kbn/i18n-react",
"@kbn/io-ts-utils"
]
}