From 54751cf17c04b675a094305768f2cf25fe5c829a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cau=C3=AA=20Marcondes?=
<55978943+cauemarcondes@users.noreply.github.com>
Date: Wed, 18 Sep 2024 16:06:13 +0100
Subject: [PATCH] [Inventory][ECO] Entities table (#193272)
Real data:
Storybook:
Tooltips:
- 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>
(cherry picked from commit e3f3c68e8d2e106740db9b4612df867c583eb512)
---
.../client/create_observability_es_client.ts | 24 +-
.../es/utils/esql_result_to_plain_objects.ts | 20 +
.../inventory/common/entities.ts | 29 +-
.../inventory/common/es_fields/entities.ts | 12 +
.../entities_grid/entities_grid.stories.tsx | 65 +
.../public/components/entities_grid/index.tsx | 229 +-
.../entities_grid/mock/entities_mock.ts | 3014 +++++++++++++++++
.../public/pages/inventory_page/index.tsx | 57 +-
.../inventory/public/routes/config.tsx | 16 +
.../routes/entities/get_latest_entities.ts | 66 +-
.../inventory/server/routes/entities/route.ts | 35 +-
.../inventory/server/utils/with_apm_span.ts | 7 -
.../inventory/tsconfig.json | 4 +-
13 files changed, 3501 insertions(+), 77 deletions(-)
create mode 100644 x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts
create mode 100644 x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts
create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx
create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts
delete mode 100644 x-pack/plugins/observability_solution/inventory/server/utils/with_apm_span.ts
diff --git a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts
index 2e57653365b0b..0011e0f17c1c0 100644
--- a/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts
+++ b/x-pack/packages/observability/observability_utils/es/client/create_observability_es_client.ts
@@ -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>;
+ esql(operationName: string, parameters: EsqlQueryRequest): Promise;
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(
operationName: string,
parameters: SearchRequest
diff --git a/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts
new file mode 100644
index 0000000000000..ad48bcb311b25
--- /dev/null
+++ b/x-pack/packages/observability/observability_utils/es/utils/esql_result_to_plain_objects.ts
@@ -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>(
+ result: ESQLSearchResponse
+): T[] {
+ return result.values.map((row) => {
+ return row.reduce>((acc, value, index) => {
+ const column = result.columns[index];
+ acc[column.name] = value;
+ return acc;
+ }, {});
+ }) as T[];
+}
diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts
index d72fa46969b8a..d8353cf3a97f0 100644
--- a/x-pack/plugins/observability_solution/inventory/common/entities.ts
+++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts
@@ -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;
+
+export const MAX_NUMBER_OF_ENTITIES = 500;
diff --git a/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts b/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts
new file mode 100644
index 0000000000000..9b619dddbb2df
--- /dev/null
+++ b/x-pack/plugins/observability_solution/inventory/common/es_fields/entities.ts
@@ -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';
diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx
new file mode 100644
index 0000000000000..b0e6c2fcc5ee4
--- /dev/null
+++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/entities_grid.stories.tsx
@@ -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({
+ id: ENTITY_LAST_SEEN,
+ direction: 'desc',
+ });
+
+ const sortedItems = useMemo(
+ () => orderBy(entitiesMock, sort.id, sort.direction),
+ [sort.direction, sort.id]
+ );
+
+ return (
+
+ );
+};
+
+export const EmptyGridExample: Story<{}> = () => {
+ const [pageIndex, setPageIndex] = useState(0);
+ const [sort, setSort] = useState({
+ id: ENTITY_LAST_SEEN,
+ direction: 'desc',
+ });
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx
index e689063882c40..c9b91f165fedd 100644
--- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx
+++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx
@@ -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 }) => (
+ <>
+ {title}
+
+
+
+ >
+);
+
+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: (
+
+ ),
+ isSortable: true,
},
{
- id: 'entityType',
- displayAsText: 'Type',
+ id: ENTITY_TYPE,
+ // keep it for accessibility purposes
+ displayAsText: entityTypeLabel,
+ display: (
+
+ ),
+ isSortable: true,
+ },
+ {
+ id: ENTITY_LAST_SEEN,
+ // keep it for accessibility purposes
+ displayAsText: entityLastSeenLabel,
+ display: (
+
+ ),
+ 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 {entity[columnEntityTableId]};
+ case ENTITY_LAST_SEEN:
+ return (
+
+ ),
+ time: (
+
+ ),
+ }}
+ />
+ );
+ case ENTITY_DISPLAY_NAME:
+ return (
+ // TODO: link to the appropriate page based on entity type https://github.com/elastic/kibana/issues/192676
+
+ {entity[columnEntityTableId]}
+
+ );
+ default:
+ return entity[columnId as EntityColumnIds] || '';
+ }
+ },
+ [entities]
);
if (loading) {
return ;
}
- 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 (
+