Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a9ac946
init
JordanSh Feb 16, 2025
a5525a5
lint
JordanSh Feb 16, 2025
6932baa
lint
JordanSh Feb 16, 2025
affd963
working on badge counter
JordanSh Feb 25, 2025
c206201
added expandable badge group
JordanSh Feb 25, 2025
903be02
adding scrolling
JordanSh Feb 26, 2025
451ae64
added inner scrolling and created a component for expandable badge group
JordanSh Feb 26, 2025
7e2cb40
merge
JordanSh Feb 26, 2025
ff1b3a7
clean
JordanSh Mar 2, 2025
cdbab4a
:wqMerge branch 'main' of https://github.com/elastic/kibana into univ…
JordanSh Mar 2, 2025
b705b99
i18n fixes
JordanSh Mar 2, 2025
7fb95e9
[CI] Auto-commit changed files from 'node scripts/eslint --no-cache -…
kibanamachine Mar 2, 2025
7c1444d
types
JordanSh Mar 2, 2025
073d443
Merge branch 'universal-entity-fields' of https://github.com/JordanSh…
JordanSh Mar 2, 2025
b2ae6df
remove override
JordanSh Mar 2, 2025
9aa6489
clean
JordanSh Mar 2, 2025
33d7468
clean
JordanSh Mar 2, 2025
afcceed
clean
JordanSh Mar 2, 2025
d303c29
types
JordanSh Mar 2, 2025
3659ae1
clean
JordanSh Mar 2, 2025
6d44bbc
clean
JordanSh Mar 2, 2025
f07b7ab
added tests for components
JordanSh Mar 2, 2025
a3f6b7f
types
JordanSh Mar 3, 2025
fe22762
types
JordanSh Mar 3, 2025
1ad3491
types
JordanSh Mar 3, 2025
c906c86
types
JordanSh Mar 3, 2025
bd6dc8b
comments
JordanSh Mar 3, 2025
ffef597
comments
JordanSh Mar 3, 2025
80e40df
fix tests
JordanSh Mar 4, 2025
88cdae1
comments
JordanSh Mar 4, 2025
8561fab
merge
JordanSh Mar 4, 2025
c13543f
review comments
JordanSh Mar 5, 2025
ecbe2ca
types
JordanSh Mar 5, 2025
78c6856
types
JordanSh Mar 5, 2025
4e4e3e1
review comments
JordanSh Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);

Expand Down Expand Up @@ -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',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 },
},
});
});
Expand All @@ -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,
Expand All @@ -114,6 +125,7 @@ describe('useDynamicEntityFlyout', () => {
});
});

expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: HostPanelKey,
Expand All @@ -135,6 +147,7 @@ describe('useDynamicEntityFlyout', () => {
});
});

expect(openFlyoutMock).toHaveBeenCalledTimes(1);
expect(openFlyoutMock).toHaveBeenCalledWith({
right: {
id: ServicePanelKey,
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -183,6 +196,8 @@ describe('useDynamicEntityFlyout', () => {
})
);
expect(onFlyoutCloseMock).toHaveBeenCalled();

expect(openFlyoutMock).toHaveBeenCalledTimes(0);
});

it('should close the flyout when closeDynamicFlyout is called', () => {
Expand All @@ -195,5 +210,6 @@ describe('useDynamicEntityFlyout', () => {
});

expect(closeFlyoutMock).toHaveBeenCalled();
expect(openFlyoutMock).toHaveBeenCalledTimes(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {},
};
};

Expand Down Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading