mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
5040e3580c
commit
e3f3c68e8d
13 changed files with 3501 additions and 77 deletions
|
@ -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
|
||||
|
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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: [],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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: <></>,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
@ -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';
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue