[Cloud Security] Generic Entity Flyout Fields Table and ECS adjustment (#215380)

This commit is contained in:
Jordan 2025-04-02 15:56:52 +03:00 committed by GitHub
parent 62a1589ed1
commit 3f7e0ade8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 438 additions and 208 deletions

View file

@ -7,13 +7,11 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
// TODO: Asset Inventory - This file is a placeholder for the ECS schema that will be used in the Asset Inventory app
export interface EntityEcs {
id: string;
name: string;
source: string;
type: 'container' | 'user' | 'host' | 'service';
tags: string[];
labels: Record<string, string>;
criticality: 'low_impact' | 'medium_impact' | 'high_impact' | 'extreme_impact' | 'unassigned';
category: string;
sub_type: string;
url: string;
}

View file

@ -34,7 +34,6 @@ import { type AddFieldFilterHandler } from '@kbn/unified-field-list';
import { generateFilters } from '@kbn/data-plugin/public';
import { type DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { EmptyComponent } from '../../common/lib/cell_actions/helpers';
import { type CriticalityLevelWithUnassigned } from '../../../common/entity_analytics/asset_criticality/types';
@ -59,6 +58,7 @@ import {
LOCAL_STORAGE_COLUMNS_SETTINGS_KEY,
LOCAL_STORAGE_COLUMNS_KEY,
} from '../constants';
import type { GenericEntityRecord } from '../types/generic_entity_record';
const gridStyle: EuiDataGridStyle = {
border: 'horizontal',
@ -125,23 +125,6 @@ const defaultColumns: AssetInventoryDefaultColumn[] = [
{ id: '@timestamp' },
];
// TODO: Asset Inventory - adjust and remove type casting once we have real universal entity data
const getEntity = (record: DataTableRecord) => {
const { _source } = record.raw;
const entityMock = {
tags: ['infrastructure', 'linux', 'admin', 'active'],
labels: { Group: 'cloud-sec-dev', Environment: 'Production' },
id: 'mock-entity-id',
criticality: 'low_impact',
} as unknown as EntityEcs;
return {
entity: { ...(_source?.entity || {}), ...entityMock },
source: _source || {},
};
};
export interface AssetInventoryDataTableProps {
state: AssetInventoryURLStateResult;
height?: number;
@ -173,13 +156,14 @@ export const AssetInventoryDataTable = ({
onFlyoutClose: () => setExpandedDoc(undefined),
});
const onExpandDocClick = (record?: DataTableRecord | undefined) => {
if (record) {
const { entity, source } = getEntity(record);
setExpandedDoc(record); // Table is expecting the same doc ref to highlight the selected row
const openTableFlyout = (doc?: DataTableRecord | undefined) => {
if (doc && doc.raw._source) {
const source = doc.raw._source as GenericEntityRecord;
setExpandedDoc(doc); // Table is expecting the same doc ref to highlight the selected row
openDynamicFlyout({
entity,
source,
entityDocId: doc.raw._id,
entityType: source.entity?.type,
entityName: source.entity?.name,
scopeId: ASSET_INVENTORY_TABLE_ID,
contextId: ASSET_INVENTORY_TABLE_ID,
});
@ -403,7 +387,7 @@ export const AssetInventoryDataTable = ({
rows={rows}
sampleSizeState={MAX_ASSETS_TO_LOAD}
expandedDoc={expandedDoc}
setExpandedDoc={onExpandDocClick}
setExpandedDoc={openTableFlyout}
renderDocumentView={EmptyComponent}
sort={sort}
rowsPerPageState={pageSize}

View file

@ -16,7 +16,6 @@ import {
HostPanelKey,
ServicePanelKey,
} from '../../flyout/entity_details/shared/constants';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
jest.mock('@kbn/expandable-flyout', () => ({
useExpandableFlyoutApi: jest.fn(),
@ -30,20 +29,6 @@ jest.mock('../../flyout/shared/hooks/use_on_expandable_flyout_close', () => ({
useOnExpandableFlyoutClose: jest.fn(),
}));
const entity: EntityEcs = {
id: '123',
name: 'test-entity',
type: 'container',
tags: ['tag1', 'tag2'],
labels: { label1: 'value1', label2: 'value2' },
criticality: 'high_impact',
category: 'test',
};
const source = {
'@timestamp': '2025-10-01T12:00:00.000Z',
};
describe('useDynamicEntityFlyout', () => {
let openFlyoutMock: jest.Mock;
let closeFlyoutMock: jest.Mock;
@ -73,18 +58,17 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'container', name: 'testUniversal' },
source,
entityDocId: '123',
entityType: 'container',
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UniversalEntityPanelKey,
params: { entity: { ...entity, type: 'container', name: 'testUniversal' }, source },
params: { entityDocId: '123', scopeId: 'scope1', contextId: 'context1' },
},
});
});
@ -96,14 +80,13 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'user', name: 'testUser' },
source,
entityType: 'user',
entityName: 'testUser',
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UserPanelKey,
@ -119,13 +102,13 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'host', name: 'testHost' },
entityType: 'host',
entityName: 'testHost',
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: HostPanelKey,
@ -141,13 +124,13 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'service', name: 'testService' },
entityType: 'service',
entityName: 'testService',
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
@ -156,48 +139,18 @@ describe('useDynamicEntityFlyout', () => {
});
});
it('should show an error toast and close flyout if entity name is missing for user, host, or service entities', () => {
it('should show an error toast if entity name is missing for user, host, or service entities', () => {
const { result } = renderHook(() =>
useDynamicEntityFlyout({ onFlyoutClose: onFlyoutCloseMock })
);
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs, source });
result.current.openDynamicFlyout({ entityType: 'user' });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(toastsMock.addDanger).toHaveBeenCalled();
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs, source });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs, source });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.any(String),
text: expect.any(String),
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
expect(openFlyoutMock).not.toHaveBeenCalled();
});
it('should close the flyout when closeDynamicFlyout is called', () => {
@ -210,6 +163,5 @@ describe('useDynamicEntityFlyout', () => {
});
expect(closeFlyoutMock).toHaveBeenCalled();
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
});
});

View file

@ -6,7 +6,6 @@
*/
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import {
ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS,
ASSET_INVENTORY_EXPAND_FLYOUT_ERROR,
@ -14,7 +13,6 @@ import {
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics';
import { i18n } from '@kbn/i18n';
import type { EsHitRecord } from '@kbn/discover-utils';
import { useKibana } from '../../common/lib/kibana';
import {
HostPanelKey,
@ -25,8 +23,9 @@ import {
import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_expandable_flyout_close';
interface InventoryFlyoutProps {
entity: EntityEcs;
source?: EsHitRecord['_source'];
entityDocId?: string;
entityType?: string;
entityName?: string;
scopeId?: string;
contextId?: string;
}
@ -36,9 +35,15 @@ export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () =>
const { notifications } = useKibana().services;
useOnExpandableFlyoutClose({ callback: onFlyoutClose });
const openDynamicFlyout = ({ entity, source, scopeId, contextId }: InventoryFlyoutProps) => {
const openDynamicFlyout = ({
entityDocId,
entityType,
entityName,
scopeId,
contextId,
}: InventoryFlyoutProps) => {
// User, Host, and Service entity flyouts rely on entity name to fetch required data
if (['user', 'host', 'service'].includes(entity.type) && !entity.name) {
if (entityType && ['user', 'host', 'service'].includes(entityType) && !entityName) {
notifications.toasts.addDanger({
title: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameTitle',
@ -55,25 +60,27 @@ export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () =>
return;
}
switch (entity.type) {
switch (entityType) {
case 'user':
openFlyout({
right: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
right: { id: UserPanelKey, params: { userName: entityName, scopeId, contextId } },
});
break;
case 'host':
openFlyout({
right: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
right: { id: HostPanelKey, params: { hostName: entityName, scopeId, contextId } },
});
break;
case 'service':
openFlyout({
right: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
right: { id: ServicePanelKey, params: { serviceName: entityName, scopeId, contextId } },
});
break;
default:
openFlyout({ right: { id: UniversalEntityPanelKey, params: { entity, source } } });
openFlyout({
right: { id: UniversalEntityPanelKey, params: { entityDocId, scopeId, contextId } },
});
break;
}

View file

@ -0,0 +1,19 @@
/*
* 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 { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
export interface GenericEntityRecord {
'@timestamp': Date;
entity: EntityEcs;
asset: {
criticality: 'low_impact' | 'medium_impact' | 'high_impact' | 'extreme_impact' | 'unassigned';
};
labels: Record<string, string>;
tags: string[];
[key: string]: unknown;
}

View file

@ -5,8 +5,13 @@
* 2.0.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. and/or licensed to Elasticsearch B.V.
* 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 { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { FieldsTable } from './fields_table';
const mockDocument = {
@ -19,6 +24,8 @@ const mockDocument = {
},
};
const mockStorageKey = 'testStorageKey';
describe('FieldsTable', () => {
it('renders the table with flattened fields and values', () => {
render(<FieldsTable document={mockDocument} />);
@ -40,7 +47,7 @@ describe('FieldsTable', () => {
render(<FieldsTable document={documentWithUndefined} />);
expect(screen.getByText('field1')).toBeInTheDocument();
expect(screen.getByText('undefined')).toBeInTheDocument();
expect(screen.getAllByText('undefined').length).toBe(2); // one rendered as field value, one rendered as icon tooltip value
});
it('renders object values correctly', () => {
@ -50,4 +57,46 @@ describe('FieldsTable', () => {
expect(screen.getByText('field1.nestedField')).toBeInTheDocument();
expect(screen.getByText('nestedValue')).toBeInTheDocument();
});
it('pins a field when pin button is clicked', () => {
render(<FieldsTable document={mockDocument} tableStorageKey={mockStorageKey} />);
const pinButton = screen.getAllByLabelText('Pin field')[0];
expect(pinButton).toBeInTheDocument();
fireEvent.click(pinButton);
expect(pinButton.getAttribute('aria-label')).toBe('Unpin field');
});
it('loads pinned fields from localStorage', () => {
localStorage.setItem(mockStorageKey, JSON.stringify(['field1']));
render(<FieldsTable document={mockDocument} tableStorageKey={mockStorageKey} />);
const pinButton = screen.getAllByLabelText('Unpin field')[0];
expect(pinButton).toBeInTheDocument();
expect(pinButton.getAttribute('aria-label')).toBe('Unpin field');
});
it('does not pin fields if tableStorageKey is not provided', () => {
render(<FieldsTable document={mockDocument} />);
// No pin button should be visible if tableStorageKey is not passed
const pinButton = screen.queryByLabelText('Pin field');
const unpinButton = screen.queryByLabelText('Unpin field');
expect(pinButton).not.toBeInTheDocument();
expect(unpinButton).not.toBeInTheDocument();
});
it('pins fields to the top based on pinned fields from localStorage', () => {
localStorage.setItem(mockStorageKey, JSON.stringify(['field2', 'field4.nestedField1']));
render(<FieldsTable document={mockDocument} tableStorageKey={mockStorageKey} />);
const firstRow = screen.getByText('field2');
const secondRow = screen.getByText('field4.nestedField1');
expect(firstRow).toBeInTheDocument();
expect(secondRow).toBeInTheDocument();
});
});

View file

@ -5,17 +5,24 @@
* 2.0.
*/
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import type { EuiInMemoryTableProps } from '@elastic/eui';
import { EuiCode, EuiCodeBlock, EuiInMemoryTable, EuiText } from '@elastic/eui';
import React from 'react';
import { EuiCode, EuiCodeBlock, EuiInMemoryTable, EuiText, EuiButtonIcon } from '@elastic/eui';
import { getFlattenedObject } from '@kbn/std';
import { i18n } from '@kbn/i18n';
import { EcsFlat } from '@elastic/ecs';
import { TableFieldNameCell } from '../../../document_details/right/components/table_field_name_cell';
interface FlattenedItem {
key: string; // flattened dot notation object path for an object;
key: string; // Flattened dot notation object path for an object;
value: unknown;
}
const TABLE_PINS_STORAGE_KEY = 'pinned_fields';
const isValidEcsField = (fieldName: string): fieldName is keyof typeof EcsFlat =>
fieldName in EcsFlat;
const getDescriptionDisplay = (value: unknown) => {
if (value === undefined) return 'undefined';
if (typeof value === 'boolean' || value === null) {
@ -33,53 +40,133 @@ const getDescriptionDisplay = (value: unknown) => {
return <EuiText size="s">{value as string}</EuiText>;
};
const search: EuiInMemoryTableProps<FlattenedItem>['search'] = {
box: {
incremental: true,
},
const getSortedFlattenedItems = (
document: Record<string, unknown>,
pinnedFields: string[]
): FlattenedItem[] => {
const flattenedItems = Object.entries(getFlattenedObject(document)).map(([key, value]) => ({
key,
value,
}));
const sortedItems = flattenedItems.sort((a, b) => {
const isAPinned = pinnedFields.includes(a.key);
const isBPinned = pinnedFields.includes(b.key);
if (isAPinned && !isBPinned) return -1;
if (!isAPinned && isBPinned) return 1;
return 0; // Keep original order if neither are pinned
});
return sortedItems;
};
const sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
sort: {
field: 'key',
direction: 'asc',
},
};
interface FieldsTableProps {
/**
* The document object containing the fields and values to be displayed in the table.
*/
document: Record<string, unknown>;
const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
initialPageSize: 100,
showPerPageOptions: false,
};
const columns: EuiInMemoryTableProps<FlattenedItem>['columns'] = [
{
field: 'key',
name: i18n.translate('xpack.securitySolution.fieldsTable.fieldColumnLabel', {
defaultMessage: 'Field',
}),
width: '25%',
},
{
field: 'value',
name: i18n.translate('xpack.securitySolution.fieldsTable.valueColumnLabel', {
defaultMessage: 'Value',
}),
render: (value: unknown) => <div style={{ width: '100%' }}>{getDescriptionDisplay(value)}</div>,
},
];
const getFlattenedItems = (resource: Record<string, unknown>) =>
Object.entries(getFlattenedObject(resource)).map(([key, value]) => ({ key, value }));
/**
* Optional key to store pinned fields in localStorage.
* If provided, pinned fields will be saved under this key.
* If not provided, pinning functionality will be disabled.
*/
tableStorageKey?: string;
}
/**
* A component that displays a table of flattened fields and values from a resource object.
* Displays a table of flattened fields and values with an option to pin items to the top.
*/
export const FieldsTable = ({ document }: { document: Record<string, unknown> }) => (
<EuiInMemoryTable
items={getFlattenedItems(document)}
columns={columns}
sorting={sorting}
search={search}
pagination={pagination}
/>
);
export const FieldsTable: React.FC<FieldsTableProps> = ({ document, tableStorageKey }) => {
const storageKey = tableStorageKey ? `${TABLE_PINS_STORAGE_KEY}-${tableStorageKey}` : null;
const [pinnedFields, setPinnedFields] = useState<string[]>([]);
// load pinned fields from localStorage into state if a storageKey is provided
useEffect(() => {
if (!storageKey) return;
const storedPinned = localStorage.getItem(storageKey);
if (storedPinned) {
setPinnedFields(JSON.parse(storedPinned));
}
}, [storageKey]);
const togglePin = useCallback(
(fieldKey: string) => {
if (!storageKey) return;
setPinnedFields((prev) => {
const updatedPinned = prev.includes(fieldKey)
? prev.filter((key) => key !== fieldKey) // remove pin
: [...prev, fieldKey]; // add pin
localStorage.setItem(storageKey, JSON.stringify(updatedPinned));
return updatedPinned;
});
},
[setPinnedFields, storageKey]
);
const sortedItems = getSortedFlattenedItems(document, pinnedFields);
const columns: EuiInMemoryTableProps<FlattenedItem>['columns'] = useMemo(
() => [
...(storageKey
? [
{
field: 'key',
name: '',
width: '40px',
render: (fieldKey: string) => {
const isPinned = pinnedFields.includes(fieldKey);
return (
<EuiButtonIcon
iconType={isPinned ? 'pinFilled' : 'pin'}
aria-label={isPinned ? 'Unpin field' : 'Pin field'}
color={isPinned ? 'primary' : 'text'}
onClick={() => togglePin(fieldKey)}
/>
);
},
},
]
: []),
{
field: 'key',
name: i18n.translate('xpack.securitySolution.fieldsTable.fieldColumnLabel', {
defaultMessage: 'Field',
}),
width: '25%',
render: (fieldName: keyof typeof EcsFlat | string, flattenedItem: FlattenedItem) => {
let dataType: string = typeof flattenedItem.value;
if (isValidEcsField(fieldName)) {
dataType = EcsFlat[fieldName].type;
}
return <TableFieldNameCell field={fieldName} dataType={dataType} />;
},
},
{
field: 'value',
name: i18n.translate('xpack.securitySolution.fieldsTable.valueColumnLabel', {
defaultMessage: 'Value',
}),
render: (value: unknown) => (
<div style={{ width: '100%' }}>{getDescriptionDisplay(value)}</div>
),
},
],
[pinnedFields, storageKey, togglePin]
);
return (
<EuiInMemoryTable
items={sortedItems}
columns={columns}
sorting={{ sort: { field: 'key', direction: 'asc' } }}
search={{ box: { incremental: true } }}
pagination={{ initialPageSize: 100, showPerPageOptions: false }}
/>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 GENERIC_FLYOUT_STORAGE_KEYS = {
OVERVIEW_FIELDS_SECTION: 'generic_entity_flyout:overview:fields_section',
OVERVIEW_FIELDS_TABLE_PINS: 'generic_entity_flyout:overview:fields_table_pins',
};

View file

@ -6,14 +6,15 @@
*/
import React from 'react';
import type { EsHitRecord } from '@kbn/discover-utils';
import { FormattedMessage } from '@kbn/i18n-react';
import { GENERIC_FLYOUT_STORAGE_KEYS } from './constants';
import type { GenericEntityRecord } from '../../../asset_inventory/types/generic_entity_record';
import { FieldsTable } from './components/fields_table';
import { ExpandableSection } from '../../document_details/right/components/expandable_section';
import { FlyoutBody } from '../../shared/components/flyout_body';
interface UniversalEntityFlyoutContentProps {
source: EsHitRecord['_source'];
source: GenericEntityRecord;
}
export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutContentProps) => {
@ -27,9 +28,12 @@ export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutCo
/>
}
expanded
localStorageKey={'universal_flyout:overview:fields_table'}
localStorageKey={GENERIC_FLYOUT_STORAGE_KEYS.OVERVIEW_FIELDS_SECTION}
>
<FieldsTable document={source || {}} />
<FieldsTable
document={source || {}}
tableStorageKey={GENERIC_FLYOUT_STORAGE_KEYS.OVERVIEW_FIELDS_TABLE_PINS}
/>
</ExpandableSection>
</FlyoutBody>
);

View file

@ -10,18 +10,25 @@ import { css } from '@emotion/react';
import type { IconType } from '@elastic/eui';
import { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup, useEuiTheme } from '@elastic/eui';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import { HeaderDataCards } from './header_data_cards';
import type { GenericEntityRecord } from '../../../asset_inventory/types/generic_entity_record';
import type { EntityType } from '../../../../common/entity_analytics/types';
import { ExpandableBadgeGroup } from './components/expandable_badge_group';
import { HeaderDataCards } from './header_data_cards';
import { EntityIconByType } from '../../../entity_analytics/components/entity_store/helpers';
import { PreferenceFormattedDate } from '../../../common/components/formatted_date';
import { FlyoutHeader } from '../../shared/components/flyout_header';
import { FlyoutTitle } from '../../shared/components/flyout_title';
const initialBadgeLimit = 3;
const maxBadgeContainerHeight = 180;
const maxBadgeContainerHeight = undefined;
const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityEcs['labels'] }) => {
const HeaderTags = ({
tags = [],
labels = {},
}: {
tags: GenericEntityRecord['tags'];
labels: GenericEntityRecord['labels'];
}) => {
const { euiTheme } = useEuiTheme();
const tagBadges = useMemo(
@ -42,7 +49,7 @@ const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityE
<>
<span
css={css`
color: ${euiTheme.colors.disabledText};
color: ${euiTheme.colors.textDisabled};
border-right: ${euiTheme.border.thick};
padding-right: ${euiTheme.size.xs};
`}
@ -59,7 +66,7 @@ const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityE
</>
),
})),
[labels, euiTheme.colors.disabledText, euiTheme.border.thick, euiTheme.size.xs]
[labels, euiTheme.colors.textDisabled, euiTheme.border.thick, euiTheme.size.xs]
);
const allBadges = [...(tagBadges || []), ...(labelBadges || [])];
@ -75,7 +82,7 @@ const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityE
interface UniversalEntityFlyoutHeaderProps {
entity: EntityEcs;
timestamp?: Date;
source: GenericEntityRecord;
}
// TODO: Asset Inventory - move this to a shared location, for now it's here as a mock since we dont have generic entities yet
@ -88,12 +95,17 @@ export const UniversalEntityIconByType: Record<GenericEntityType | EntityType, I
container: 'container',
};
const isDate = (value: unknown): value is Date => value instanceof Date;
export const UniversalEntityFlyoutHeader = ({
entity,
timestamp,
source,
}: UniversalEntityFlyoutHeaderProps) => {
const { euiTheme } = useEuiTheme();
const docTimestamp = source?.['@timestamp'];
const timestamp = isDate(docTimestamp) ? docTimestamp : undefined;
return (
<>
<FlyoutHeader>
@ -114,25 +126,27 @@ export const UniversalEntityFlyoutHeader = ({
/>
</EuiFlexItem>
</EuiFlexGroup>
<>
<div
css={css`
margin-bottom: ${euiTheme.size.s};
`}
>
{(source.tags || source.labels) && (
<>
<EuiSpacer size="s" />
<HeaderTags tags={source.tags} labels={source.labels} />
</>
)}
</div>
<HeaderDataCards
id={entity.id}
type={entity.type}
subType={entity.sub_type}
criticality={source.asset.criticality}
/>
</>
</FlyoutHeader>
<div
css={css`
margin: ${euiTheme.size.s};
`}
>
<HeaderDataCards
id={entity.id}
type={entity.type}
category={entity.category}
criticality={entity.criticality}
/>
{(entity.tags || entity.labels) && (
<>
<EuiSpacer size="s" />
<HeaderTags tags={entity.tags} labels={entity.labels} />
</>
)}
</div>
</>
);
};

View file

@ -30,12 +30,12 @@ import { ResponsiveDataCards } from './components/responsive_data_cards';
export const HeaderDataCards = ({
criticality,
id,
category,
subType,
type,
}: {
criticality?: CriticalityLevelWithUnassigned;
id: string;
category: string;
subType: string;
type: string;
}) => {
const [selectValue, setSelectValue] = useState<CriticalityLevelWithUnassigned>(
@ -85,15 +85,6 @@ export const HeaderDataCards = ({
),
description: <EuiTextTruncate text={id} />,
},
{
title: i18n.translate(
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.categoryLabel',
{
defaultMessage: 'Category',
}
),
description: <EuiTextTruncate text={category || ''} />,
},
{
title: i18n.translate(
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.typeLabel',
@ -103,8 +94,17 @@ export const HeaderDataCards = ({
),
description: <EuiTextTruncate text={type || ''} />,
},
{
title: i18n.translate(
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.subtypeLabel',
{
defaultMessage: 'Sub Type',
}
),
description: <EuiTextTruncate text={subType || ''} />,
},
],
[selectValue, id, category, type]
[selectValue, id, subType, type]
);
return <ResponsiveDataCards cards={cards} collapseWidth={750} />;

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/search-types';
import type { estypes } from '@elastic/elasticsearch';
import { lastValueFrom } from 'rxjs';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { ASSET_INVENTORY_INDEX_PATTERN } from '../../../../asset_inventory/constants';
import type { GenericEntityRecord } from '../../../../asset_inventory/types/generic_entity_record';
import { useKibana } from '../../../../common/lib/kibana';
type GenericEntityRequest = IKibanaSearchRequest<estypes.SearchRequest>;
type GenericEntityResponse = IKibanaSearchResponse<estypes.SearchResponse<GenericEntityRecord>>;
const fetchGenericEntity = async (
dataService: DataPublicPluginStart,
docId: string
): Promise<GenericEntityResponse> => {
return lastValueFrom(
dataService.search.search<GenericEntityRequest, GenericEntityResponse>({
params: {
index: ASSET_INVENTORY_INDEX_PATTERN,
query: {
term: { _id: docId },
},
},
})
);
};
export const useGetGenericEntity = (docId: string) => {
const { data: dataService } = useKibana().services;
return useQuery({
queryKey: ['use-get-generic-entity-key', docId],
queryFn: () => fetchGenericEntity(dataService, docId),
select: (response) => response.rawResponse.hits.hits[0], // extracting result out of ES
});
};

View file

@ -7,20 +7,37 @@
import React, { useEffect } from 'react';
import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
import {
uiMetricService,
UNIVERSAL_ENTITY_FLYOUT_OPENED,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { METRIC_TYPE } from '@kbn/analytics';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import type { EsHitRecord } from '@kbn/discover-utils';
import {
UNIVERSAL_ENTITY_FLYOUT_OPENED,
uiMetricService,
} from '@kbn/cloud-security-posture-common/utils/ui_metrics';
import { EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useGetGenericEntity } from './hooks/use_get_generic_entity';
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
import { UniversalEntityFlyoutHeader } from './header';
import { UniversalEntityFlyoutContent } from './content';
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
interface CommonError {
body: {
error: string;
message: string;
statusCode: number;
};
}
export const isCommonError = (error: unknown): error is CommonError => {
// @ts-ignore TS2339: Property body does not exist on type {}
if (!error?.body || !error?.body?.error || !error?.body?.message || !error?.body?.statusCode) {
return false;
}
return true;
};
export interface UniversalEntityPanelProps {
entity: EntityEcs;
source: EsHitRecord['_source'];
entityDocId: string;
/** this is because FlyoutPanelProps defined params as Record<string, unknown> {@link FlyoutPanelProps#params} */
[key: string]: unknown;
}
@ -30,20 +47,64 @@ export interface UniversalEntityPanelExpandableFlyoutProps extends FlyoutPanelPr
params: UniversalEntityPanelProps;
}
const isDate = (value: unknown): value is Date => value instanceof Date;
export const UniversalEntityPanel = ({ entityDocId }: UniversalEntityPanelProps) => {
const getGenericEntity = useGetGenericEntity(entityDocId);
export const UniversalEntityPanel = ({ entity, source }: UniversalEntityPanelProps) => {
useEffect(() => {
uiMetricService.trackUiMetric(METRIC_TYPE.COUNT, UNIVERSAL_ENTITY_FLYOUT_OPENED);
}, [entity]);
if (getGenericEntity.data?._id) {
uiMetricService.trackUiMetric(METRIC_TYPE.COUNT, UNIVERSAL_ENTITY_FLYOUT_OPENED);
}
}, [getGenericEntity.data?._id]);
const docTimestamp = source?.['@timestamp'];
const timestamp = isDate(docTimestamp) ? docTimestamp : undefined;
if (getGenericEntity.isLoading) {
return (
<>
<EuiLoadingSpinner size="m" style={{ position: 'absolute', inset: '50%' }} />
</>
);
}
if (!getGenericEntity.data?._source || getGenericEntity.isError) {
return (
<>
<EuiEmptyPrompt
color="danger"
iconType="warning"
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.universalEntityFlyout.errorTitle"
defaultMessage="Unable to load entity"
/>
</h2>
}
body={
isCommonError(getGenericEntity.error) ? (
<p>
<FormattedMessage
id="xpack.securitySolution.universalEntityFlyout.errorBody"
defaultMessage="{error} {statusCode}: {body}"
values={{
error: getGenericEntity.error.body.error,
statusCode: getGenericEntity.error.body.statusCode,
body: getGenericEntity.error.body.message,
}}
/>
</p>
) : undefined
}
/>
</>
);
}
const source = getGenericEntity.data._source;
const entity = getGenericEntity.data._source.entity;
return (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<UniversalEntityFlyoutHeader entity={entity} timestamp={timestamp} />
<UniversalEntityFlyoutHeader entity={entity} source={source} />
<UniversalEntityFlyoutContent source={source} />
</>
);