mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Cloud Security] Generic Entity Flyout Fields Table and ECS adjustment (#215380)
This commit is contained in:
parent
62a1589ed1
commit
3f7e0ade8d
13 changed files with 438 additions and 208 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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
|
||||
});
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue