[Cloud Security] Asset Inventory - Universal Flyout Header Boxes, Tags, Fields Components (#211366)

This commit is contained in:
Jordan 2025-03-05 19:13:35 +02:00 committed by GitHub
parent bccbb933c0
commit 827219b82a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 730 additions and 103 deletions

View file

@ -11,6 +11,9 @@
export interface EntityEcs {
id: string;
name: string;
type: 'universal' | 'user' | 'host' | 'service';
timestamp: Date;
type: 'container' | 'user' | 'host' | 'service';
tags: string[];
labels: Record<string, string>;
criticality: 'low_impact' | 'medium_impact' | 'high_impact' | 'extreme_impact' | 'unassigned';
category: string;
}

View file

@ -115,7 +115,12 @@ export const FieldsSelectorTable = ({
};
const fields = useMemo<Field[]>(
() =>
filterFieldsBySearch(dataView.fields.getAll(), columns, searchQuery, isFilterSelectedEnabled),
filterFieldsBySearch(
dataView.fields?.getAll(),
columns,
searchQuery,
isFilterSelectedEnabled
),
[dataView, columns, searchQuery, isFilterSelectedEnabled]
);
@ -171,7 +176,7 @@ export const FieldsSelectorTable = ({
];
const error = useMemo(() => {
if (!dataView || dataView.fields.length === 0) {
if (!dataView || dataView.fields?.length === 0) {
return i18n.translate('xpack.securitySolution.assetInventory.allAssets.fieldsModalError', {
defaultMessage: 'No fields found in the data view',
});

View file

@ -30,11 +30,18 @@ jest.mock('../../flyout/shared/hooks/use_on_expandable_flyout_close', () => ({
useOnExpandableFlyoutClose: jest.fn(),
}));
const entity = {
const entity: EntityEcs = {
id: '123',
name: 'test-entity',
type: 'universal',
timestamp: new Date(),
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', () => {
@ -66,16 +73,18 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'universal', name: 'testUniversal' },
entity: { ...entity, type: 'container', name: 'testUniversal' },
source,
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UniversalEntityPanelKey,
params: { entity: { ...entity, type: 'universal', name: 'testUniversal' } },
params: { entity: { ...entity, type: 'container', name: 'testUniversal' }, source },
},
});
});
@ -88,11 +97,13 @@ describe('useDynamicEntityFlyout', () => {
act(() => {
result.current.openDynamicFlyout({
entity: { ...entity, type: 'user', name: 'testUser' },
source,
scopeId: 'scope1',
contextId: 'context1',
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: UserPanelKey,
@ -114,6 +125,7 @@ describe('useDynamicEntityFlyout', () => {
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: HostPanelKey,
@ -135,6 +147,7 @@ describe('useDynamicEntityFlyout', () => {
});
});
expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
@ -149,7 +162,7 @@ describe('useDynamicEntityFlyout', () => {
);
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs });
result.current.openDynamicFlyout({ entity: { type: 'user' } as EntityEcs, source });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
@ -161,7 +174,7 @@ describe('useDynamicEntityFlyout', () => {
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs });
result.current.openDynamicFlyout({ entity: { type: 'host' } as EntityEcs, source });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
@ -173,7 +186,7 @@ describe('useDynamicEntityFlyout', () => {
expect(onFlyoutCloseMock).toHaveBeenCalled();
act(() => {
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs });
result.current.openDynamicFlyout({ entity: { type: 'service' } as EntityEcs, source });
});
expect(toastsMock.addDanger).toHaveBeenCalledWith(
@ -183,6 +196,8 @@ describe('useDynamicEntityFlyout', () => {
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
});
it('should close the flyout when closeDynamicFlyout is called', () => {
@ -195,5 +210,6 @@ describe('useDynamicEntityFlyout', () => {
});
expect(closeFlyoutMock).toHaveBeenCalled();
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
});
});

View file

@ -14,6 +14,7 @@ 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,53 +26,19 @@ import { useOnExpandableFlyoutClose } from '../../flyout/shared/hooks/use_on_exp
interface InventoryFlyoutProps {
entity: EntityEcs;
source?: EsHitRecord['_source'];
scopeId?: string;
contextId?: string;
}
interface SecurityFlyoutPanelsCommonParams {
scopeId?: string;
contextId?: string;
[key: string]: unknown;
}
type FlyoutParams =
| {
id: typeof UniversalEntityPanelKey;
params: { entity: EntityEcs };
}
| { id: typeof UserPanelKey; params: { userName: string } & SecurityFlyoutPanelsCommonParams }
| { id: typeof HostPanelKey; params: { hostName: string } & SecurityFlyoutPanelsCommonParams }
| {
id: typeof ServicePanelKey;
params: { serviceName: string } & SecurityFlyoutPanelsCommonParams;
};
const getFlyoutParamsByEntity = ({
entity,
scopeId,
contextId,
}: InventoryFlyoutProps): FlyoutParams => {
const entitiesFlyoutParams: Record<EntityEcs['type'], FlyoutParams> = {
universal: { id: UniversalEntityPanelKey, params: { entity } },
user: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
host: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
service: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
} as const;
return entitiesFlyoutParams[entity.type];
};
export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () => void }) => {
const { openFlyout, closeFlyout } = useExpandableFlyoutApi();
const { notifications } = useKibana().services;
useOnExpandableFlyoutClose({ callback: onFlyoutClose });
const openDynamicFlyout = ({ entity, scopeId, contextId }: InventoryFlyoutProps) => {
const entityFlyoutParams = getFlyoutParamsByEntity({ entity, scopeId, contextId });
const openDynamicFlyout = ({ entity, source, scopeId, contextId }: InventoryFlyoutProps) => {
// User, Host, and Service entity flyouts rely on entity name to fetch required data
if (entity.type !== 'universal' && !entity.name) {
if (['user', 'host', 'service'].includes(entity.type) && !entity.name) {
notifications.toasts.addDanger({
title: i18n.translate(
'xpack.securitySolution.assetInventory.openFlyout.missingEntityNameTitle',
@ -88,14 +55,29 @@ export const useDynamicEntityFlyout = ({ onFlyoutClose }: { onFlyoutClose: () =>
return;
}
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);
switch (entity.type) {
case 'user':
openFlyout({
right: { id: UserPanelKey, params: { userName: entity.name, scopeId, contextId } },
});
break;
case 'host':
openFlyout({
right: { id: HostPanelKey, params: { hostName: entity.name, scopeId, contextId } },
});
break;
case 'service':
openFlyout({
right: { id: ServicePanelKey, params: { serviceName: entity.name, scopeId, contextId } },
});
break;
openFlyout({
right: {
id: entityFlyoutParams.id || UniversalEntityPanelKey,
params: entityFlyoutParams.params,
},
});
default:
openFlyout({ right: { id: UniversalEntityPanelKey, params: { entity, source } } });
break;
}
uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ASSET_INVENTORY_EXPAND_FLYOUT_SUCCESS);
};
const closeDynamicFlyout = () => {

View file

@ -146,12 +146,19 @@ const getDefaultQuery = ({ query, filters }: AssetsBaseURLQuery): URLQuery => ({
});
// TODO: Asset Inventory - adjust and remove type casting once we have real universal entity data
const getEntity = (row: DataTableRecord): EntityEcs => {
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 {
id: (row.flattened['asset.name'] as string) || '',
name: (row.flattened['asset.name'] as string) || '',
timestamp: row.flattened['@timestamp'] as Date,
type: 'universal',
entity: { ...(_source?.entity || {}), ...entityMock },
source: _source || {},
};
};
@ -181,12 +188,13 @@ export const AllAssets = () => {
onFlyoutClose: () => setExpandedDoc(undefined),
});
const onExpandDocClick = (doc?: DataTableRecord | undefined) => {
if (doc) {
const entity = getEntity(doc);
setExpandedDoc(doc); // Table is expecting the same doc ref to highlight the selected row
const onExpandDocClick = (record?: DataTableRecord | undefined) => {
if (record) {
const { entity, source } = getEntity(record);
setExpandedDoc(record); // Table is expecting the same record ref to highlight the selected row
openDynamicFlyout({
entity,
source,
scopeId: ASSET_INVENTORY_TABLE_ID,
contextId: ASSET_INVENTORY_TABLE_ID,
});

View file

@ -235,7 +235,7 @@ const AssetCriticalityModal: React.FC<ModalProps> = ({
<EuiModalBody>
<EuiSuperSelect
id={basicSelectId}
options={options}
options={assetCriticalityOptions}
valueOfSelected={value}
onChange={setNewValue}
aria-label={PICK_ASSET_CRITICALITY}
@ -280,13 +280,15 @@ const option = (
<AssetCriticalityBadge criticalityLevel={level} style={{ lineHeight: 'inherit' }} />
),
});
const options: Array<EuiSuperSelectOption<CriticalityLevelWithUnassigned>> = [
option('unassigned'),
option('low_impact'),
option('medium_impact'),
option('high_impact'),
option('extreme_impact'),
];
export const assetCriticalityOptions: Array<EuiSuperSelectOption<CriticalityLevelWithUnassigned>> =
[
option('unassigned'),
option('low_impact'),
option('medium_impact'),
option('high_impact'),
option('extreme_impact'),
];
export const AssetCriticalityAccordion = React.memo(AssetCriticalityAccordionComponent);
AssetCriticalityAccordion.displayName = 'AssetCriticalityAccordion';

View file

@ -0,0 +1,62 @@
/*
* 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 { render, screen, fireEvent } from '@testing-library/react';
import { ExpandableBadgeGroup } from './expandable_badge_group';
const badgeProps = [
{ color: 'hollow', children: 'Badge 1' },
{ color: 'hollow', children: 'Badge 2' },
{ color: 'hollow', children: 'Badge 3' },
{ color: 'hollow', children: 'Badge 4' },
];
const badgePropsWithElement = [
{ color: 'hollow', children: <span>{'Badge 1 with element'}</span> },
{ color: 'hollow', children: <span>{'Badge 2 with element'}</span> },
{ color: 'hollow', children: <span>{'Badge 3 with element'}</span> },
{ color: 'hollow', children: <span>{'Badge 4 with element'}</span> },
];
describe('ExpandableBadgeGroup', () => {
it('renders all badges when initialBadgeLimit is not set', () => {
render(<ExpandableBadgeGroup badges={badgeProps} />);
badgeProps.forEach((badge) => {
expect(screen.getByText(badge.children)).toBeInTheDocument();
});
});
it('renders limited badges and expand button when initialBadgeLimit is set', () => {
render(<ExpandableBadgeGroup badges={badgeProps} initialBadgeLimit={2} />);
expect(screen.getByText('Badge 1')).toBeInTheDocument();
expect(screen.getByText('Badge 2')).toBeInTheDocument();
expect(screen.queryByText('Badge 3')).not.toBeInTheDocument();
expect(screen.queryByText('Badge 4')).not.toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
});
it('expands to show all badges when expand button is clicked', () => {
render(<ExpandableBadgeGroup badges={badgeProps} initialBadgeLimit={2} />);
fireEvent.click(screen.getByText('+2'));
badgeProps.forEach((badge) => {
expect(screen.getByText(badge.children)).toBeInTheDocument();
});
});
it('applies maxHeight style when maxHeight is set', () => {
const { container } = render(<ExpandableBadgeGroup badges={badgeProps} maxHeight={100} />);
expect(container.firstChild).toHaveStyle('max-height: 100px');
});
it('renders badges with children as React elements', () => {
render(<ExpandableBadgeGroup badges={badgePropsWithElement} />);
badgePropsWithElement.forEach((badge) => {
expect(screen.getByText(badge.children.props.children)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,71 @@
/*
* 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, { useState, useMemo } from 'react';
import { EuiBadge, EuiBadgeGroup } from '@elastic/eui';
import type { EuiBadgeProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
interface ExpandableBadgeGroupProps {
/** Array of EuiBadges to display */
badges: EuiBadgeProps[];
/** The initial number of badges to show before expanding. Defaults to 'all' if not set */
initialBadgeLimit?: number | 'all';
/** The maximum height of the badge group in pixels. If not set the expandable container will not have inner scrolling */
maxHeight?: number;
}
/**
* A component that displays a group of badges with a limited initial display and an expansion option.
*
* The component initially shows a limited number of badges (or all if `initialBadgeLimit` is not set) and provides a "+n" badge to expand and show all badges.
* The badge group is scrollable if the badges exceed the `maxHeight`.
*/
export const ExpandableBadgeGroup = ({
badges,
initialBadgeLimit = 'all',
maxHeight,
}: ExpandableBadgeGroupProps) => {
const [visibleBadgesCount, setVisibleBadgesCount] = useState<number | 'all'>(initialBadgeLimit);
// Calculate the number of remaining badges. If 'all' badges are shown, the remaining count is 0.
const remainingCount = visibleBadgesCount === 'all' ? 0 : badges.length - visibleBadgesCount;
const maxScrollHeight = maxHeight ? `${maxHeight}px` : 'initial';
const badgeElements = useMemo(
() => badges.map((badge, index) => <EuiBadge key={index} {...badge} />),
[badges]
);
return (
<EuiBadgeGroup
gutterSize="s"
style={{
maxHeight: maxScrollHeight,
overflowY: 'auto',
}}
>
{
// Show all badges if 'all' is set, otherwise show the first `badgesToShow` badges
visibleBadgesCount === 'all' ? badgeElements : badgeElements.slice(0, visibleBadgesCount)
}
{
// Show the expand badge if there are remaining badges to show
remainingCount > 0 && visibleBadgesCount !== 'all' && (
<EuiBadge
color="hollow"
onClick={() => setVisibleBadgesCount('all')}
onClickAriaLabel={i18n.translate(
'xpack.securitySolution.expandableBadgeGroup.expandBadgeAriaLabel',
{ defaultMessage: 'Expand Remaining Badges' }
)}
>{`+${remainingCount}`}</EuiBadge>
)
}
</EuiBadgeGroup>
);
};

View file

@ -0,0 +1,53 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { FieldsTable } from './fields_table';
const mockDocument = {
field1: 'value1',
field2: true,
field3: null,
field4: {
nestedField1: 'nestedValue1',
nestedField2: 123,
},
};
describe('FieldsTable', () => {
it('renders the table with flattened fields and values', () => {
render(<FieldsTable document={mockDocument} />);
expect(screen.getByText('field1')).toBeInTheDocument();
expect(screen.getByText('value1')).toBeInTheDocument();
expect(screen.getByText('field2')).toBeInTheDocument();
expect(screen.getByText('true')).toBeInTheDocument();
expect(screen.getByText('field3')).toBeInTheDocument();
expect(screen.getByText('null')).toBeInTheDocument();
expect(screen.getByText('field4.nestedField1')).toBeInTheDocument();
expect(screen.getByText('nestedValue1')).toBeInTheDocument();
expect(screen.getByText('field4.nestedField2')).toBeInTheDocument();
expect(screen.getByText('123')).toBeInTheDocument();
});
it('renders undefined values correctly', () => {
const documentWithUndefined = { field1: undefined };
render(<FieldsTable document={documentWithUndefined} />);
expect(screen.getByText('field1')).toBeInTheDocument();
expect(screen.getByText('undefined')).toBeInTheDocument();
});
it('renders object values correctly', () => {
const documentWithObject = { field1: { nestedField: 'nestedValue' } };
render(<FieldsTable document={documentWithObject} />);
expect(screen.getByText('field1.nestedField')).toBeInTheDocument();
expect(screen.getByText('nestedValue')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,85 @@
/*
* 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 { EuiInMemoryTableProps } from '@elastic/eui';
import { EuiCode, EuiCodeBlock, EuiInMemoryTable, EuiText } from '@elastic/eui';
import React from 'react';
import { getFlattenedObject } from '@kbn/std';
import { i18n } from '@kbn/i18n';
interface FlattenedItem {
key: string; // flattened dot notation object path for an object;
value: unknown;
}
const getDescriptionDisplay = (value: unknown) => {
if (value === undefined) return 'undefined';
if (typeof value === 'boolean' || value === null) {
return <EuiCode>{JSON.stringify(value)}</EuiCode>;
}
if (typeof value === 'object') {
return (
<EuiCodeBlock isCopyable={true} overflowHeight={300}>
{JSON.stringify(value, null, 2)}
</EuiCodeBlock>
);
}
return <EuiText size="s">{value as string}</EuiText>;
};
const search: EuiInMemoryTableProps<FlattenedItem>['search'] = {
box: {
incremental: true,
},
};
const sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
sort: {
field: 'key',
direction: 'asc',
},
};
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 }));
/**
* A component that displays a table of flattened fields and values from a resource object.
*/
export const FieldsTable = ({ document }: { document: Record<string, unknown> }) => (
<EuiInMemoryTable
items={getFlattenedItems(document)}
columns={columns}
sorting={sorting}
search={search}
pagination={pagination}
/>
);

View file

@ -0,0 +1,33 @@
/*
* 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 { render, screen } from '@testing-library/react';
import { ResponsiveDataCards } from './responsive_data_cards';
const cards = [
{ title: 'Card 1', description: 'Description 1' },
{ title: 'Card 2', description: 'Description 2' },
{ title: 'Card 3', description: 'Description 3' },
{ title: 'Card 4', description: 'Description 4' },
];
describe('ResponsiveDataCards', () => {
it('renders the correct number of cards', () => {
render(<ResponsiveDataCards cards={cards} />);
const renderedCards = screen.getAllByTestId('responsive-data-card');
expect(renderedCards).toHaveLength(cards.length);
});
it('renders card titles and descriptions correctly', () => {
render(<ResponsiveDataCards cards={cards} />);
cards.forEach(({ title, description }) => {
expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByText(description)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { css } from '@emotion/react';
import { EuiCard, EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import type { EuiCardProps } from '@elastic/eui/src/components/card/card';
interface ResponsiveDataCardsProps {
/**
* An array of EuiCardProps objects, defining the cards to be displayed.
*/
cards: Array<Pick<EuiCardProps, 'title' | 'description'>>;
/**
* The width (in pixels) at which the cards should collapse from a row layout to two columns layout.
* Defaults to 750.
*/
collapseWidth?: number;
}
/**
* A component that displays a group of data cards in a responsive layout.
* Depending on the width of the container, the cards will be displayed in a row layout or a two columns layout.
*/
export const ResponsiveDataCards = ({ cards, collapseWidth = 750 }: ResponsiveDataCardsProps) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
css={css`
container-type: inline-size;
`}
gutterSize="s"
responsive={false}
wrap
>
{cards.map((card, index) => (
<EuiFlexItem
key={index}
css={css`
flex-basis: calc(25% - ${euiTheme.size.s});
width: calc(25% - ${euiTheme.size.s});
@container (max-width: ${collapseWidth}px) {
flex: calc(50% - ${euiTheme.size.s});
width: calc(50% - ${euiTheme.size.s});
}
`}
>
<EuiCard
data-test-subj="responsive-data-card"
title={card.title}
description={card.description}
textAlign="left"
titleSize="xs"
hasBorder
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -6,13 +6,31 @@
*/
import React from 'react';
import type { EntityEcs } from '@kbn/securitysolution-ecs/src/entity';
import type { EsHitRecord } from '@kbn/discover-utils';
import { FormattedMessage } from '@kbn/i18n-react';
import { FieldsTable } from './components/fields_table';
import { ExpandableSection } from '../../document_details/right/components/expandable_section';
import { FlyoutBody } from '../../shared/components/flyout_body';
interface UniversalEntityFlyoutContentProps {
entity: EntityEcs;
source: EsHitRecord['_source'];
}
export const UniversalEntityFlyoutContent = ({ entity }: UniversalEntityFlyoutContentProps) => {
return <FlyoutBody>{entity.type}</FlyoutBody>;
export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutContentProps) => {
return (
<FlyoutBody>
<ExpandableSection
title={
<FormattedMessage
id="xpack.securitySolution.universalEntityFlyout.flyoutContent.expandableSection.fieldsLabel"
defaultMessage="Fields"
/>
}
expanded
localStorageKey={'universal_flyout:overview:fields_table'}
>
<FieldsTable document={source || {}} />
</ExpandableSection>
</FlyoutBody>
);
};

View file

@ -5,34 +5,134 @@
* 2.0.
*/
import { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import React, { useMemo } from 'react';
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 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';
interface UniversalEntityFlyoutHeaderProps {
entity: EntityEcs;
}
const initialBadgeLimit = 3;
const maxBadgeContainerHeight = 180;
const HeaderTags = ({ tags, labels }: { tags: EntityEcs['tags']; labels: EntityEcs['labels'] }) => {
const { euiTheme } = useEuiTheme();
const tagBadges = useMemo(
() =>
tags?.map((tag) => ({
color: 'hollow',
children: tag,
})),
[tags]
);
const labelBadges = useMemo(
() =>
labels &&
Object.entries(labels)?.map(([key, value]) => ({
color: 'hollow',
children: (
<>
<span
css={css`
color: ${euiTheme.colors.disabledText};
border-right: ${euiTheme.border.thick};
padding-right: ${euiTheme.size.xs};
`}
>
{key}
</span>
<span
css={css`
padding-left: ${euiTheme.size.xs};
`}
>
{value}
</span>
</>
),
})),
[labels, euiTheme.colors.disabledText, euiTheme.border.thick, euiTheme.size.xs]
);
const allBadges = [...(tagBadges || []), ...(labelBadges || [])];
export const UniversalEntityFlyoutHeader = ({ entity }: UniversalEntityFlyoutHeaderProps) => {
return (
<FlyoutHeader data-test-subj="service-panel-header">
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
<EuiFlexItem grow={false}>
<EuiText size="xs" data-test-subj={'service-panel-header-lastSeen'}>
<PreferenceFormattedDate value={entity?.timestamp} />
<EuiSpacer size="xs" />
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* @ts-ignore Fix it once genetric entity is introduce*/}
<FlyoutTitle title={entity?.id} iconType={EntityIconByType[entity?.type]} />
</EuiFlexItem>
</EuiFlexGroup>
</FlyoutHeader>
<ExpandableBadgeGroup
badges={allBadges}
initialBadgeLimit={initialBadgeLimit}
maxHeight={maxBadgeContainerHeight}
/>
);
};
interface UniversalEntityFlyoutHeaderProps {
entity: EntityEcs;
timestamp?: Date;
}
// TODO: Asset Inventory - move this to a shared location, for now it's here as a mock since we dont have generic entities yet
enum GenericEntityType {
container = 'container',
}
export const UniversalEntityIconByType: Record<GenericEntityType | EntityType, IconType> = {
...EntityIconByType,
container: 'container',
};
export const UniversalEntityFlyoutHeader = ({
entity,
timestamp,
}: UniversalEntityFlyoutHeaderProps) => {
const { euiTheme } = useEuiTheme();
return (
<>
<FlyoutHeader>
<EuiFlexGroup gutterSize="s" responsive={false} direction="column">
{timestamp && (
<EuiFlexItem grow={false}>
<EuiText size="xs">
<PreferenceFormattedDate value={timestamp} />
<EuiSpacer size="xs" />
</EuiText>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<FlyoutTitle
title={entity?.name}
iconType={UniversalEntityIconByType[entity?.type] || 'globe'}
iconColor="primary"
/>
</EuiFlexItem>
</EuiFlexGroup>
</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

@ -0,0 +1,111 @@
/*
* 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.
*/
/*
* 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 the Elastic License
* 2.0.
*/
import React, { useState, useMemo } from 'react';
import {
EuiButtonIcon,
EuiCopy,
EuiFlexGroup,
EuiFlexItem,
EuiSuperSelect,
EuiTextTruncate,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { i18n } from '@kbn/i18n';
import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types';
import { assetCriticalityOptions } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector';
import { ResponsiveDataCards } from './components/responsive_data_cards';
export const HeaderDataCards = ({
criticality,
id,
category,
type,
}: {
criticality?: CriticalityLevelWithUnassigned;
id: string;
category: string;
type: string;
}) => {
const [selectValue, setSelectValue] = useState<CriticalityLevelWithUnassigned>(
criticality || 'unassigned'
);
const cards = useMemo(
() => [
{
title: i18n.translate(
'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.criticalityLabel',
{
defaultMessage: 'Criticality',
}
),
description: (
<div
css={css`
width: fit-content;
`}
>
<EuiSuperSelect
popoverProps={{
repositionOnScroll: true,
panelMinWidth: 200,
}}
fullWidth={false}
compressed
hasDividers
options={assetCriticalityOptions}
valueOfSelected={selectValue}
onChange={(newValue) => setSelectValue(newValue)}
/>
</div>
),
},
{
title: (
<EuiFlexGroup justifyContent={'spaceBetween'} wrap={false} responsive={false}>
<EuiFlexItem grow={false}>{'ID'}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={id}>
{(copy) => <EuiButtonIcon onClick={copy} iconType="document" color="text" />}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
),
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',
{
defaultMessage: 'Type',
}
),
description: <EuiTextTruncate text={type || ''} />,
},
],
[selectValue, id, category, type]
);
return <ResponsiveDataCards cards={cards} collapseWidth={750} />;
};

View file

@ -13,12 +13,14 @@ import {
} 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 { UniversalEntityFlyoutHeader } from './header';
import { UniversalEntityFlyoutContent } from './content';
import { FlyoutNavigation } from '../../shared/components/flyout_navigation';
export interface UniversalEntityPanelProps {
entity: EntityEcs;
source: EsHitRecord['_source'];
/** this is because FlyoutPanelProps defined params as Record<string, unknown> {@link FlyoutPanelProps#params} */
[key: string]: unknown;
}
@ -28,16 +30,21 @@ export interface UniversalEntityPanelExpandableFlyoutProps extends FlyoutPanelPr
params: UniversalEntityPanelProps;
}
export const UniversalEntityPanel = ({ entity }: UniversalEntityPanelProps) => {
const isDate = (value: unknown): value is Date => value instanceof Date;
export const UniversalEntityPanel = ({ entity, source }: UniversalEntityPanelProps) => {
useEffect(() => {
uiMetricService.trackUiMetric(METRIC_TYPE.COUNT, UNIVERSAL_ENTITY_FLYOUT_OPENED);
}, [entity]);
const docTimestamp = source?.['@timestamp'];
const timestamp = isDate(docTimestamp) ? docTimestamp : undefined;
return (
<>
<FlyoutNavigation flyoutIsExpandable={false} />
<UniversalEntityFlyoutHeader entity={entity} />
<UniversalEntityFlyoutContent entity={entity} />
<UniversalEntityFlyoutHeader entity={entity} timestamp={timestamp} />
<UniversalEntityFlyoutContent source={source} />
</>
);
};

View file

@ -28,6 +28,10 @@ export interface FlyoutTitleProps {
* Optional icon type. If null, no icon is displayed
*/
iconType?: EuiButtonEmptyProps['iconType'];
/**
* Optional icon color
*/
iconColor?: EuiButtonEmptyProps['color'];
/**
* Optional boolean to indicate if title is a link. If true, a popout icon is appended
* and the title text is changed to link color
@ -43,12 +47,13 @@ export interface FlyoutTitleProps {
* Title component with optional icon to indicate the type of document, can be used for text or a link
*/
export const FlyoutTitle: FC<FlyoutTitleProps> = memo(
({ title, iconType, isLink = false, 'data-test-subj': dataTestSubj }) => {
({ title, iconType, iconColor, isLink = false, 'data-test-subj': dataTestSubj }) => {
const { euiTheme } = useEuiTheme();
const titleIcon = useMemo(() => {
return iconType ? (
<EuiIcon
color={iconColor}
type={iconType}
size="m"
className="eui-alignBaseline"
@ -58,7 +63,7 @@ export const FlyoutTitle: FC<FlyoutTitleProps> = memo(
`}
/>
) : null;
}, [dataTestSubj, iconType, euiTheme.size.xs]);
}, [iconType, iconColor, dataTestSubj, euiTheme.size.xs]);
const titleComponent = useMemo(() => {
return (