[Inventory][ECO] Show alerts for entities (#195250)

## Summary

Show alerts related to entities

close https://github.com/elastic/kibana/issues/194381 

### Checklist

- change default sorting from last seen to alertsCount
- when alertsCount is not available server side sorting fallbacks to
last seen
- [Change app route from /app/observability/inventory to
/app/inventory](57598d05fb)
(causing issue when importing observability plugin
- refactoring: move columns into seperate file 




https://github.com/user-attachments/assets/ea3abc5a-0581-41e7-a174-6655a39c1133



### How to test
- run any synthtrace scenario ex`node scripts/synthtrace
infra_hosts_with_apm_hosts.ts`
- create a rule (SLO or apm) 
- click on the alert count

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Cauê Marcondes <55978943+cauemarcondes@users.noreply.github.com>
This commit is contained in:
Katerina 2024-10-15 14:51:34 +03:00 committed by GitHub
parent 562bf21fbd
commit c0bd82b30c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1056 additions and 105 deletions

View file

@ -0,0 +1,224 @@
/*
* 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 { joinByKey } from './join_by_key';
describe('joinByKey', () => {
it('joins by a string key', () => {
const joined = joinByKey(
[
{
serviceName: 'opbeans-node',
avg: 10,
},
{
serviceName: 'opbeans-node',
count: 12,
},
{
serviceName: 'opbeans-java',
avg: 11,
},
{
serviceName: 'opbeans-java',
p95: 18,
},
],
'serviceName'
);
expect(joined.length).toBe(2);
expect(joined).toEqual([
{
serviceName: 'opbeans-node',
avg: 10,
count: 12,
},
{
serviceName: 'opbeans-java',
avg: 11,
p95: 18,
},
]);
});
it('joins by a record key', () => {
const joined = joinByKey(
[
{
key: {
serviceName: 'opbeans-node',
transactionName: '/api/opbeans-node',
},
avg: 10,
},
{
key: {
serviceName: 'opbeans-node',
transactionName: '/api/opbeans-node',
},
count: 12,
},
{
key: {
serviceName: 'opbeans-java',
transactionName: '/api/opbeans-java',
},
avg: 11,
},
{
key: {
serviceName: 'opbeans-java',
transactionName: '/api/opbeans-java',
},
p95: 18,
},
],
'key'
);
expect(joined.length).toBe(2);
expect(joined).toEqual([
{
key: {
serviceName: 'opbeans-node',
transactionName: '/api/opbeans-node',
},
avg: 10,
count: 12,
},
{
key: {
serviceName: 'opbeans-java',
transactionName: '/api/opbeans-java',
},
avg: 11,
p95: 18,
},
]);
});
it('joins by multiple keys', () => {
const data = [
{
serviceName: 'opbeans-node',
environment: 'production',
type: 'service',
},
{
serviceName: 'opbeans-node',
environment: 'stage',
type: 'service',
},
{
serviceName: 'opbeans-node',
hostName: 'host-1',
},
{
containerId: 'containerId',
},
];
const alerts = [
{
serviceName: 'opbeans-node',
environment: 'production',
type: 'service',
alertCount: 10,
},
{
containerId: 'containerId',
alertCount: 1,
},
{
hostName: 'host-1',
environment: 'production',
alertCount: 5,
},
];
const joined = joinByKey(
[...data, ...alerts],
['serviceName', 'environment', 'hostName', 'containerId']
);
expect(joined.length).toBe(5);
expect(joined).toEqual([
{ environment: 'stage', serviceName: 'opbeans-node', type: 'service' },
{ hostName: 'host-1', serviceName: 'opbeans-node' },
{ alertCount: 10, environment: 'production', serviceName: 'opbeans-node', type: 'service' },
{ alertCount: 1, containerId: 'containerId' },
{ alertCount: 5, environment: 'production', hostName: 'host-1' },
]);
});
it('uses the custom merge fn to replace items', () => {
const joined = joinByKey(
[
{
serviceName: 'opbeans-java',
values: ['a'],
},
{
serviceName: 'opbeans-node',
values: ['a'],
},
{
serviceName: 'opbeans-node',
values: ['b'],
},
{
serviceName: 'opbeans-node',
values: ['c'],
},
],
'serviceName',
(a, b) => ({
...a,
...b,
values: a.values.concat(b.values),
})
);
expect(joined.find((item) => item.serviceName === 'opbeans-node')?.values).toEqual([
'a',
'b',
'c',
]);
});
it('deeply merges objects', () => {
const joined = joinByKey(
[
{
serviceName: 'opbeans-node',
properties: {
foo: '',
},
},
{
serviceName: 'opbeans-node',
properties: {
bar: '',
},
},
],
'serviceName'
);
expect(joined[0]).toEqual({
serviceName: 'opbeans-node',
properties: {
foo: '',
bar: '',
},
});
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 { UnionToIntersection, ValuesType } from 'utility-types';
import { merge, castArray } from 'lodash';
import stableStringify from 'json-stable-stringify';
export type JoinedReturnType<
T extends Record<string, any>,
U extends UnionToIntersection<T>
> = Array<
Partial<U> & {
[k in keyof T]: T[k];
}
>;
type ArrayOrSingle<T> = T | T[];
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>
>(items: T[], key: V): JoinedReturnType<T, U>;
export function joinByKey<
T extends Record<string, any>,
U extends UnionToIntersection<T>,
V extends ArrayOrSingle<keyof T & keyof U>,
W extends JoinedReturnType<T, U>,
X extends (a: T, b: T) => ValuesType<W>
>(items: T[], key: V, mergeFn: X): W;
export function joinByKey(
items: Array<Record<string, any>>,
key: string | string[],
mergeFn: Function = (a: Record<string, any>, b: Record<string, any>) => merge({}, a, b)
) {
const keys = castArray(key);
// Create a map to quickly query the key of group.
const map = new Map();
items.forEach((current) => {
// The key of the map is a stable JSON string of the values from given keys.
// We need stable JSON string to support plain object values.
const stableKey = stableStringify(keys.map((k) => current[k]));
if (map.has(stableKey)) {
const item = map.get(stableKey);
// delete and set the key to put it last
map.delete(stableKey);
map.set(stableKey, mergeFn(item, current));
} else {
map.set(stableKey, { ...current });
}
});
return [...map.values()];
}

View file

@ -6,6 +6,9 @@
*/
import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema';
import {
HOST_NAME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
AGENT_NAME,
CLOUD_PROVIDER,
CONTAINER_ID,
@ -15,9 +18,6 @@ import {
ENTITY_IDENTITY_FIELDS,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
HOST_NAME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
} from '@kbn/observability-shared-plugin/common';
import { isRight } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
@ -28,8 +28,19 @@ export const entityTypeRt = t.union([
t.literal('container'),
]);
export const entityColumnIdsRt = t.union([
t.literal(ENTITY_DISPLAY_NAME),
t.literal(ENTITY_LAST_SEEN),
t.literal(ENTITY_TYPE),
t.literal('alertsCount'),
]);
export type EntityColumnIds = t.TypeOf<typeof entityColumnIdsRt>;
export type EntityType = t.TypeOf<typeof entityTypeRt>;
export const defaultEntitySortField: EntityColumnIds = 'alertsCount';
export const MAX_NUMBER_OF_ENTITIES = 500;
export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({
@ -79,6 +90,7 @@ interface BaseEntity {
[ENTITY_DISPLAY_NAME]: string;
[ENTITY_DEFINITION_ID]: string;
[ENTITY_IDENTITY_FIELDS]: string | string[];
alertsCount?: number;
[key: string]: any;
}

View file

@ -0,0 +1,90 @@
/*
* 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 {
ENTITY_DEFINITION_ID,
ENTITY_DISPLAY_NAME,
ENTITY_ID,
ENTITY_LAST_SEEN,
} from '@kbn/observability-shared-plugin/common';
import { HostEntity, ServiceEntity } from '../entities';
import { parseIdentityFieldValuesToKql } from './parse_identity_field_values_to_kql';
const commonEntityFields = {
[ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z',
[ENTITY_ID]: '1',
[ENTITY_DISPLAY_NAME]: 'entity_name',
[ENTITY_DEFINITION_ID]: 'entity_definition_id',
alertCount: 3,
};
describe('parseIdentityFieldValuesToKql', () => {
it('should return the value when identityFields is a single string', () => {
const entity: ServiceEntity = {
'agent.name': 'node',
'entity.identityFields': 'service.name',
'service.name': 'my-service',
'entity.type': 'service',
...commonEntityFields,
};
const result = parseIdentityFieldValuesToKql({ entity });
expect(result).toEqual('service.name: "my-service"');
});
it('should return values when identityFields is an array of strings', () => {
const entity: ServiceEntity = {
'agent.name': 'node',
'entity.identityFields': ['service.name', 'service.environment'],
'service.name': 'my-service',
'entity.type': 'service',
'service.environment': 'staging',
...commonEntityFields,
};
const result = parseIdentityFieldValuesToKql({ entity });
expect(result).toEqual('service.name: "my-service" AND service.environment: "staging"');
});
it('should return an empty string if identityFields is empty string', () => {
const entity: ServiceEntity = {
'agent.name': 'node',
'entity.identityFields': '',
'service.name': 'my-service',
'entity.type': 'service',
...commonEntityFields,
};
const result = parseIdentityFieldValuesToKql({ entity });
expect(result).toEqual('');
});
it('should return an empty array if identityFields is empty array', () => {
const entity: ServiceEntity = {
'agent.name': 'node',
'entity.identityFields': [],
'service.name': 'my-service',
'entity.type': 'service',
...commonEntityFields,
};
const result = parseIdentityFieldValuesToKql({ entity });
expect(result).toEqual('');
});
it('should ignore fields that are not present in the entity', () => {
const entity: HostEntity = {
'entity.identityFields': ['host.name', 'foo.bar'],
'host.name': 'my-host',
'entity.type': 'host',
'cloud.provider': null,
...commonEntityFields,
};
const result = parseIdentityFieldValuesToKql({ entity });
expect(result).toEqual('host.name: "my-host"');
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { ENTITY_IDENTITY_FIELDS } from '@kbn/observability-shared-plugin/common';
import { Entity } from '../entities';
type Operator = 'AND';
export function parseIdentityFieldValuesToKql({
entity,
operator = 'AND',
}: {
entity: Entity;
operator?: Operator;
}) {
const mapping: string[] = [];
const identityFields = entity[ENTITY_IDENTITY_FIELDS];
if (identityFields) {
const fields = [identityFields].flat();
fields.forEach((field) => {
if (field in entity) {
mapping.push(`${[field]}: "${entity[field as keyof Entity]}"`);
}
});
}
return mapping.join(` ${operator} `);
}

View file

@ -16,6 +16,7 @@
"features",
"unifiedSearch",
"data",
"ruleRegistry",
"share"
],
"requiredBundles": ["kibanaReact"],

View file

@ -0,0 +1,86 @@
/*
* 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 { type KibanaReactContextValue } from '@kbn/kibana-react-plugin/public';
import { render, screen } from '@testing-library/react';
import { AlertsBadge } from './alerts_badge';
import * as useKibana from '../../hooks/use_kibana';
import { HostEntity, ServiceEntity } from '../../../common/entities';
describe('AlertsBadge', () => {
jest.spyOn(useKibana, 'useKibana').mockReturnValue({
services: {
http: {
basePath: {
prepend: (path: string) => path,
},
},
},
} as unknown as KibanaReactContextValue<useKibana.InventoryKibanaContext>);
afterAll(() => {
jest.clearAllMocks();
});
it('render alerts badge for a host entity', () => {
const entity: HostEntity = {
'entity.lastSeenTimestamp': 'foo',
'entity.id': '1',
'entity.type': 'host',
'entity.displayName': 'foo',
'entity.identityFields': 'host.name',
'host.name': 'foo',
'entity.definitionId': 'host',
'cloud.provider': null,
alertsCount: 1,
};
render(<AlertsBadge entity={entity} />);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual(
'/app/observability/alerts?_a=(kuery:\'host.name: "foo"\',status:active)'
);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('1');
});
it('render alerts badge for a service entity', () => {
const entity: ServiceEntity = {
'entity.lastSeenTimestamp': 'foo',
'agent.name': 'node',
'entity.id': '1',
'entity.type': 'service',
'entity.displayName': 'foo',
'entity.identityFields': 'service.name',
'service.name': 'bar',
'entity.definitionId': 'host',
'cloud.provider': null,
alertsCount: 5,
};
render(<AlertsBadge entity={entity} />);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual(
'/app/observability/alerts?_a=(kuery:\'service.name: "bar"\',status:active)'
);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('5');
});
it('render alerts badge for a service entity with multiple identity fields', () => {
const entity: ServiceEntity = {
'entity.lastSeenTimestamp': 'foo',
'agent.name': 'node',
'entity.id': '1',
'entity.type': 'service',
'entity.displayName': 'foo',
'entity.identityFields': ['service.name', 'service.environment'],
'service.name': 'bar',
'service.environment': 'prod',
'entity.definitionId': 'host',
'cloud.provider': null,
alertsCount: 2,
};
render(<AlertsBadge entity={entity} />);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual(
'/app/observability/alerts?_a=(kuery:\'service.name: "bar" AND service.environment: "prod"\',status:active)'
);
expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('2');
});
});

View file

@ -0,0 +1,49 @@
/*
* 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 rison from '@kbn/rison';
import { EuiBadge, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Entity } from '../../../common/entities';
import { useKibana } from '../../hooks/use_kibana';
import { parseIdentityFieldValuesToKql } from '../../../common/utils/parse_identity_field_values_to_kql';
export function AlertsBadge({ entity }: { entity: Entity }) {
const {
services: {
http: { basePath },
},
} = useKibana();
const activeAlertsHref = basePath.prepend(
`/app/observability/alerts?_a=${rison.encode({
kuery: parseIdentityFieldValuesToKql({ entity }),
status: 'active',
})}`
);
return (
<EuiToolTip
position="bottom"
content={i18n.translate(
'xpack.inventory.home.serviceAlertsTable.tooltip.activeAlertsExplanation',
{
defaultMessage: 'Active alerts',
}
)}
>
<EuiBadge
data-test-subj="inventoryAlertsBadgeLink"
iconType="warning"
color="danger"
iconSide="left"
href={activeAlertsHref}
>
{entity.alertsCount}
</EuiBadge>
</EuiToolTip>
);
}

View file

@ -0,0 +1,113 @@
/*
* 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 { EuiButtonIcon, EuiDataGridColumn, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import {
ENTITY_DISPLAY_NAME,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
const alertsLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel', {
defaultMessage: 'Alerts',
});
const alertsTooltip = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.alertsTooltip', {
defaultMessage: 'The count of the active alerts',
});
const entityNameLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel', {
defaultMessage: 'Entity name',
});
const entityNameTooltip = i18n.translate(
'xpack.inventory.entitiesGrid.euiDataGrid.entityNameTooltip',
{
defaultMessage: 'Name of the entity (entity.displayName)',
}
);
const entityTypeLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeLabel', {
defaultMessage: 'Type',
});
const entityTypeTooltip = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip', {
defaultMessage: 'Type of entity (entity.type)',
});
const entityLastSeenLabel = i18n.translate(
'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenLabel',
{
defaultMessage: 'Last seen',
}
);
const entityLastSeenToolip = i18n.translate(
'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip',
{
defaultMessage: 'Timestamp of last received data for entity (entity.lastSeenTimestamp)',
}
);
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>
</>
);
export const getColumns = ({
showAlertsColumn,
}: {
showAlertsColumn: boolean;
}): EuiDataGridColumn[] => {
return [
...(showAlertsColumn
? [
{
id: 'alertsCount',
displayAsText: alertsLabel,
isSortable: true,
display: <CustomHeaderCell title={alertsLabel} tooltipContent={alertsTooltip} />,
initialWidth: 100,
schema: 'numeric',
},
]
: []),
{
id: ENTITY_DISPLAY_NAME,
// keep it for accessibility purposes
displayAsText: entityNameLabel,
display: <CustomHeaderCell title={entityNameLabel} tooltipContent={entityNameTooltip} />,
isSortable: true,
},
{
id: ENTITY_TYPE,
// keep it for accessibility purposes
displayAsText: entityTypeLabel,
display: <CustomHeaderCell title={entityTypeLabel} tooltipContent={entityTypeTooltip} />,
isSortable: true,
},
{
id: ENTITY_LAST_SEEN,
// keep it for accessibility purposes
displayAsText: entityLastSeenLabel,
display: (
<CustomHeaderCell title={entityLastSeenLabel} tooltipContent={entityLastSeenToolip} />
),
defaultSortDirection: 'desc',
isSortable: true,
schema: 'datetime',
},
];
};

View file

@ -5,103 +5,32 @@
* 2.0.
*/
import {
EuiButtonIcon,
EuiDataGrid,
EuiDataGridCellValueElementProps,
EuiDataGridColumn,
EuiDataGridSorting,
EuiLoadingSpinner,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react';
import { last } from 'lodash';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
ENTITY_DISPLAY_NAME,
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
import { EntityColumnIds, EntityType } from '../../../common/entities';
import { APIReturnType } from '../../api';
import { BadgeFilterWithPopover } from '../badge_filter_with_popover';
import { getColumns } from './grid_columns';
import { AlertsBadge } from '../alerts_badge/alerts_badge';
import { EntityName } from './entity_name';
import { EntityType } from '../../../common/entities';
import { getEntityTypeLabel } from '../../utils/get_entity_type_label';
type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>;
type LatestEntities = InventoryEntitiesAPIReturnType['entities'];
export 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: ENTITY_DISPLAY_NAME,
// keep it for accessibility purposes
displayAsText: entityNameLabel,
display: (
<CustomHeaderCell
title={entityNameLabel}
tooltipContent="Name of the entity (entity.displayName)"
/>
),
isSortable: true,
},
{
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',
},
];
interface Props {
loading: boolean;
entities: LatestEntities;
@ -125,8 +54,6 @@ export function EntitiesGrid({
onChangeSort,
onFilterByType,
}: Props) {
const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id));
const onSort: EuiDataGridSorting['onSort'] = useCallback(
(newSortingColumns) => {
const lastItem = last(newSortingColumns);
@ -137,6 +64,19 @@ export function EntitiesGrid({
[onChangeSort]
);
const showAlertsColumn = useMemo(
() => entities?.some((entity) => entity?.alertsCount && entity?.alertsCount > 0),
[entities]
);
const columnVisibility = useMemo(
() => ({
visibleColumns: getColumns({ showAlertsColumn }).map(({ id }) => id),
setVisibleColumns: () => {},
}),
[showAlertsColumn]
);
const renderCellValue = useCallback(
({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => {
const entity = entities[rowIndex];
@ -146,6 +86,9 @@ export function EntitiesGrid({
const columnEntityTableId = columnId as EntityColumnIds;
switch (columnEntityTableId) {
case 'alertsCount':
return entity?.alertsCount ? <AlertsBadge entity={entity} /> : null;
case ENTITY_TYPE:
const entityType = entity[columnEntityTableId];
return (
@ -203,8 +146,8 @@ export function EntitiesGrid({
'xpack.inventory.entitiesGrid.euiDataGrid.inventoryEntitiesGridLabel',
{ defaultMessage: 'Inventory entities grid' }
)}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
columns={getColumns({ showAlertsColumn })}
columnVisibility={columnVisibility}
rowCount={entities.length}
renderCellValue={renderCellValue}
gridStyle={{ border: 'horizontal', header: 'shade' }}

View file

@ -15,24 +15,29 @@ export const entitiesMock = [
'entity.type': 'host',
'entity.displayName': 'Spider-Man',
'entity.id': '0',
alertsCount: 3,
},
{
'entity.lastSeenTimestamp': '2024-06-16T21:48:16.259Z',
'entity.type': 'service',
'entity.displayName': 'Iron Man',
'entity.id': '1',
alertsCount: 3,
},
{
'entity.lastSeenTimestamp': '2024-04-28T03:31:57.528Z',
'entity.type': 'host',
'entity.displayName': 'Captain America',
'entity.id': '2',
alertsCount: 10,
},
{
'entity.lastSeenTimestamp': '2024-05-14T11:32:04.275Z',
'entity.type': 'host',
'entity.displayName': 'Hulk',
'entity.id': '3',
alertsCount: 1,
},
{
'entity.lastSeenTimestamp': '2023-12-05T13:33:54.028Z',
@ -1630,6 +1635,7 @@ export const entitiesMock = [
'entity.displayName':
'Sed dignissim libero a diam sagittis, in convallis leo pellentesque. Cras ut sapien sed lacus scelerisque vehicula. Pellentesque at purus pulvinar, mollis justo hendrerit, pharetra purus. Morbi dapibus, augue et volutpat ultricies, neque quam sollicitudin mauris, vitae luctus ex libero id erat. Suspendisse risus lectus, scelerisque vel odio sed.',
'entity.id': '269',
alertsCount: 4,
},
{
'entity.lastSeenTimestamp': '2023-10-22T13:49:53.092Z',

View file

@ -17,10 +17,9 @@ import {
ENTITY_LAST_SEEN,
ENTITY_TYPE,
} from '@kbn/observability-shared-plugin/common';
import { defaultEntityDefinitions } from '../../../common/entities';
import { defaultEntityDefinitions, EntityColumnIds } from '../../../common/entities';
import { useInventoryParams } from '../../hooks/use_inventory_params';
import { useKibana } from '../../hooks/use_kibana';
import { EntityColumnIds } from '../entities_grid';
const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN];

View file

@ -7,7 +7,7 @@
import { EuiDataGridSorting } from '@elastic/eui';
import React from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import { EntityType } from '../../../common/entities';
import { EntityColumnIds, EntityType } from '../../../common/entities';
import { EntitiesGrid } from '../../components/entities_grid';
import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
@ -76,7 +76,7 @@ export function InventoryPage() {
path: {},
query: {
...query,
sortField: sorting.id,
sortField: sorting.id as EntityColumnIds,
sortDirection: sorting.direction,
},
});

View file

@ -117,7 +117,7 @@ export class InventoryPlugin
defaultMessage: 'Inventory',
}),
euiIconType: 'logoObservability',
appRoute: '/app/observability/inventory',
appRoute: '/app/inventory',
category: DEFAULT_APP_CATEGORIES.observability,
visibleIn: ['sideNav', 'globalSearch'],
order: 8200,

View file

@ -8,10 +8,9 @@ import { toNumberRt } from '@kbn/io-ts-utils';
import { Outlet, createRouter } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { ENTITY_LAST_SEEN } from '@kbn/observability-shared-plugin/common';
import { InventoryPageTemplate } from '../components/inventory_page_template';
import { InventoryPage } from '../pages/inventory_page';
import { entityTypesRt } from '../../common/entities';
import { defaultEntitySortField, entityTypesRt, entityColumnIdsRt } from '../../common/entities';
/**
* The array of route definitions to be used when the application
@ -27,7 +26,7 @@ const inventoryRoutes = {
params: t.type({
query: t.intersection([
t.type({
sortField: t.string,
sortField: entityColumnIdsRt,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
pageIndex: toNumberRt,
}),
@ -39,7 +38,7 @@ const inventoryRoutes = {
}),
defaults: {
query: {
sortField: ENTITY_LAST_SEEN,
sortField: defaultEntitySortField,
sortDirection: 'desc',
pageIndex: '0',
},

View file

@ -0,0 +1,47 @@
/*
* 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 { isEmpty } from 'lodash';
import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types';
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
import { InventoryRouteHandlerResources } from '../../routes/types';
export type AlertsClient = Awaited<ReturnType<typeof createAlertsClient>>;
export async function createAlertsClient({
plugins,
request,
}: Pick<InventoryRouteHandlerResources, 'plugins' | 'request'>) {
const ruleRegistryPluginStart = await plugins.ruleRegistry.start();
const alertsClient = await ruleRegistryPluginStart.getRacClientWithRequest(request);
const alertsIndices = await alertsClient.getAuthorizedAlertsIndices([
'logs',
'infrastructure',
'apm',
'slo',
'observability',
]);
if (!alertsIndices || isEmpty(alertsIndices)) {
throw Error('No alert indices exist');
}
type RequiredParams = ESSearchRequest & {
size: number;
track_total_hits: boolean | number;
};
return {
search<TParams extends RequiredParams>(
searchParams: TParams
): Promise<InferSearchResponseOf<ParsedTechnicalFields, TParams>> {
return alertsClient.find({
...searchParams,
index: alertsIndices.join(','),
}) as Promise<any>;
},
};
}

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 { getGroupByTermsAgg } from './get_group_by_terms_agg';
import { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
describe('getGroupByTermsAgg', () => {
it('should return an empty object when fields is empty', () => {
const fields: IdentityFieldsPerEntityType = new Map();
const result = getGroupByTermsAgg(fields);
expect(result).toEqual({});
});
it('should correctly generate aggregation structure for service, host, and container entity types', () => {
const fields: IdentityFieldsPerEntityType = new Map([
['service', ['service.name', 'service.environment']],
['host', ['host.name']],
['container', ['container.id', 'foo.bar']],
]);
const result = getGroupByTermsAgg(fields);
expect(result).toEqual({
service: {
composite: {
size: 500,
sources: [
{ 'service.name': { terms: { field: 'service.name' } } },
{ 'service.environment': { terms: { field: 'service.environment' } } },
],
},
},
host: {
composite: {
size: 500,
sources: [{ 'host.name': { terms: { field: 'host.name' } } }],
},
},
container: {
composite: {
size: 500,
sources: [
{
'container.id': {
terms: { field: 'container.id' },
},
},
{
'foo.bar': { terms: { field: 'foo.bar' } },
},
],
},
},
});
});
it('should override maxSize when provided', () => {
const fields: IdentityFieldsPerEntityType = new Map([['host', ['host.name']]]);
const result = getGroupByTermsAgg(fields, 10);
expect(result.host.composite.size).toBe(10);
});
});

View file

@ -0,0 +1,26 @@
/*
* 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 { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
export const getGroupByTermsAgg = (fields: IdentityFieldsPerEntityType, maxSize = 500) => {
return Array.from(fields).reduce((acc, [entityType, identityFields]) => {
acc[entityType] = {
composite: {
size: maxSize,
sources: identityFields.map((field) => ({
[field]: {
terms: {
field,
},
},
})),
},
};
return acc;
}, {} as Record<string, any>);
};

View file

@ -0,0 +1,64 @@
/*
* 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 { ContainerEntity, HostEntity, ServiceEntity } from '../../../common/entities';
import {
ENTITY_DEFINITION_ID,
ENTITY_DISPLAY_NAME,
ENTITY_ID,
ENTITY_LAST_SEEN,
} from '@kbn/observability-shared-plugin/common';
import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
const commonEntityFields = {
[ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z',
[ENTITY_ID]: '1',
[ENTITY_DISPLAY_NAME]: 'entity_name',
[ENTITY_DEFINITION_ID]: 'entity_definition_id',
alertCount: 3,
};
describe('getIdentityFields', () => {
it('should return an empty Map when no entities are provided', () => {
const result = getIdentityFieldsPerEntityType([]);
expect(result.size).toBe(0);
});
it('should return a Map with unique entity types and their respective identity fields', () => {
const serviceEntity: ServiceEntity = {
'agent.name': 'node',
'entity.identityFields': ['service.name', 'service.environment'],
'service.name': 'my-service',
'entity.type': 'service',
...commonEntityFields,
};
const hostEntity: HostEntity = {
'entity.identityFields': ['host.name'],
'host.name': 'my-host',
'entity.type': 'host',
'cloud.provider': null,
...commonEntityFields,
};
const containerEntity: ContainerEntity = {
'entity.identityFields': 'container.id',
'host.name': 'my-host',
'entity.type': 'container',
'cloud.provider': null,
'container.id': '123',
...commonEntityFields,
};
const mockEntities = [serviceEntity, hostEntity, containerEntity];
const result = getIdentityFieldsPerEntityType(mockEntities);
expect(result.size).toBe(3);
expect(result.get('service')).toEqual(['service.name', 'service.environment']);
expect(result.get('host')).toEqual(['host.name']);
expect(result.get('container')).toEqual(['container.id']);
});
});

View file

@ -0,0 +1,21 @@
/*
* 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 { ENTITY_IDENTITY_FIELDS, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common';
import { Entity, EntityType } from '../../../common/entities';
export type IdentityFieldsPerEntityType = Map<EntityType, string[]>;
export const getIdentityFieldsPerEntityType = (entities: Entity[]) => {
const identityFieldsPerEntityType: IdentityFieldsPerEntityType = new Map();
entities.forEach((entity) =>
identityFieldsPerEntityType.set(entity[ENTITY_TYPE], [entity[ENTITY_IDENTITY_FIELDS]].flat())
);
return identityFieldsPerEntityType;
};

View file

@ -8,11 +8,13 @@
import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import { ENTITY_LAST_SEEN } from '@kbn/observability-shared-plugin/common';
import {
ENTITIES_LATEST_ALIAS,
MAX_NUMBER_OF_ENTITIES,
type EntityType,
Entity,
type Entity,
type EntityColumnIds,
} from '../../../common/entities';
import { getEntityDefinitionIdWhereClause, getEntityTypesWhereClause } from './query_helper';
@ -25,15 +27,18 @@ export async function getLatestEntities({
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
sortDirection: 'asc' | 'desc';
sortField: string;
sortField: EntityColumnIds;
entityTypes?: EntityType[];
kuery?: string;
}) {
const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', {
// alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default.
const entitiesSortField = sortField === 'alertsCount' ? ENTITY_LAST_SEEN : sortField;
const request = {
query: `FROM ${ENTITIES_LATEST_ALIAS}
| ${getEntityTypesWhereClause(entityTypes)}
| ${getEntityDefinitionIdWhereClause()}
| SORT ${sortField} ${sortDirection}
| SORT ${entitiesSortField} ${sortDirection}
| LIMIT ${MAX_NUMBER_OF_ENTITIES}
`,
filter: {
@ -41,7 +46,9 @@ export async function getLatestEntities({
filter: [...kqlQuery(kuery)],
},
},
});
};
const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', request);
return esqlResultToPlainObjects<Entity>(latestEntitiesEsqlResponse);
}

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 { kqlQuery, termQuery } from '@kbn/observability-plugin/server';
import { ALERT_STATUS, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils';
import { AlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client';
import { getGroupByTermsAgg } from './get_group_by_terms_agg';
import { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
import { EntityType } from '../../../common/entities';
interface Bucket {
key: Record<string, any>;
doc_count: number;
}
type EntityTypeBucketsAggregation = Record<EntityType, { buckets: Bucket[] }>;
export async function getLatestEntitiesAlerts({
alertsClient,
kuery,
identityFieldsPerEntityType,
}: {
alertsClient: AlertsClient;
kuery?: string;
identityFieldsPerEntityType: IdentityFieldsPerEntityType;
}): Promise<Array<{ [key: string]: any; alertsCount: number; type: EntityType }>> {
if (identityFieldsPerEntityType.size === 0) {
return [];
}
const filter = {
size: 0,
track_total_hits: false,
query: {
bool: {
filter: [...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE), ...kqlQuery(kuery)],
},
},
};
const response = await alertsClient.search({
...filter,
aggs: getGroupByTermsAgg(identityFieldsPerEntityType),
});
const aggregations = response.aggregations as EntityTypeBucketsAggregation;
const alerts = Array.from(identityFieldsPerEntityType).flatMap(([entityType]) => {
const entityAggregation = aggregations?.[entityType];
const buckets = entityAggregation.buckets ?? [];
return buckets.map((bucket: Bucket) => ({
alertsCount: bucket.doc_count,
type: entityType,
...bucket.key,
}));
});
return alerts;
}

View file

@ -8,10 +8,15 @@ import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants';
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 { entityTypeRt } from '../../../common/entities';
import { orderBy } from 'lodash';
import { joinByKey } from '@kbn/observability-utils/array/join_by_key';
import { entityTypeRt, entityColumnIdsRt, Entity } from '../../../common/entities';
import { createInventoryServerRoute } from '../create_inventory_server_route';
import { getEntityTypes } from './get_entity_types';
import { getLatestEntities } from './get_latest_entities';
import { createAlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client';
import { getLatestEntitiesAlerts } from './get_latest_entities_alerts';
import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type';
export const getEntityTypesRoute = createInventoryServerRoute({
endpoint: 'GET /internal/inventory/entities/types',
@ -36,7 +41,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
params: t.type({
query: t.intersection([
t.type({
sortField: t.string,
sortField: entityColumnIdsRt,
sortDirection: t.union([t.literal('asc'), t.literal('desc')]),
}),
t.partial({
@ -48,7 +53,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
options: {
tags: ['access:inventory'],
},
handler: async ({ params, context, logger }) => {
handler: async ({ params, context, logger, plugins, request }) => {
const coreContext = await context.core;
const inventoryEsClient = createObservabilityEsClient({
client: coreContext.elasticsearch.client.asCurrentUser,
@ -58,15 +63,40 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({
const { sortDirection, sortField, entityTypes, kuery } = params.query;
const latestEntities = await getLatestEntities({
inventoryEsClient,
sortDirection,
sortField,
entityTypes,
const [alertsClient, latestEntities] = await Promise.all([
createAlertsClient({ plugins, request }),
getLatestEntities({
inventoryEsClient,
sortDirection,
sortField,
entityTypes,
kuery,
}),
]);
const identityFieldsPerEntityType = getIdentityFieldsPerEntityType(latestEntities);
const alerts = await getLatestEntitiesAlerts({
identityFieldsPerEntityType,
alertsClient,
kuery,
});
return { entities: latestEntities };
const joined = joinByKey(
[...latestEntities, ...alerts],
[...identityFieldsPerEntityType.values()].flat()
).filter((entity) => entity['entity.id']);
return {
entities:
sortField === 'alertsCount'
? orderBy(
joined,
[(item: Entity) => item?.alertsCount === undefined, sortField],
['asc', sortDirection] // push entities without alertsCount to the end
)
: joined,
};
},
});

View file

@ -14,6 +14,10 @@ import type {
DataViewsServerPluginStart,
} from '@kbn/data-views-plugin/server';
import { FeaturesPluginSetup } from '@kbn/features-plugin/server';
import {
RuleRegistryPluginStartContract,
RuleRegistryPluginSetupContract,
} from '@kbn/rule-registry-plugin/server';
/* eslint-disable @typescript-eslint/no-empty-interface*/
export interface ConfigSchema {}
@ -23,12 +27,14 @@ export interface InventorySetupDependencies {
inference: InferenceServerSetup;
dataViews: DataViewsServerPluginSetup;
features: FeaturesPluginSetup;
ruleRegistry: RuleRegistryPluginSetupContract;
}
export interface InventoryStartDependencies {
entityManager: EntityManagerServerPluginStart;
inference: InferenceServerStart;
dataViews: DataViewsServerPluginStart;
ruleRegistry: RuleRegistryPluginStartContract;
}
export interface InventoryServerSetup {}

View file

@ -46,6 +46,10 @@
"@kbn/elastic-agent-utils",
"@kbn/custom-icons",
"@kbn/ui-theme",
"@kbn/rison",
"@kbn/rule-registry-plugin",
"@kbn/observability-plugin",
"@kbn/rule-data-utils",
"@kbn/spaces-plugin",
"@kbn/cloud-plugin"
]