Repository: elastic/kibana
Pull request #215380: [Cloud Security] Generic Entity Flyout Fields Table and ECS adjustment
Author: login: JordanSh
Labels: name: release_note:skip
description: Skip the PR/issue when compiling release notes
name: backport:skip
description: This PR does not require backporting
name: Team:Cloud Security
description: Cloud Security team related
name: v9.1.0
Created: 2025-03-20T17:12:42Z | Merged: 2025-04-02T12:56:53Z

Resolves #214334

## Summary

This PR adds:

- Adjustments to Entity and its usage according to [ECS](https://github.com/elastic/ecs/pull/2434/files#diff-3d609a1de712dcf13a03e90c9f6373796e79b238f63dfb9b7c0bfcb31c0d8715)
- Entity flyout now load entity from dataview
- Field table now supports icons for ECS known fields, with fallback to field's value type
- Pin to top of the table
- Pins are saved to local storage and will persist throughout generic entity flyout instances 
- Updated tags and labels location

https://github.com/user-attachments/assets/1328fca3-ce1d-4d4e-bc27-ae1a23797413


Linked issue #214334: Inventory Flyout - Adjust entity ECS type, Add Pin to table, add ECS type icons
## Summary

Add various code and UI improvements to the fields table component in the inventory flyout:

- Adjust the Entity ECS in Kibana to match the now [merged proposal for ECS](https://github.com/elastic/ecs/pull/2434)

- Tags and Labels should be moved to out of the Entity definition 

- Add pins to top of the table

- Add ECS type icons for fields

![Image](https://github.com/user-attachments/assets/6c814a08-efba-457e-9951-b8bb0e489dae)

## Out of scope

Highlighted fields, combined with opening the secondary flyout panel for more controls. There was a design change to use the Pin system like in other flyout tables in Kibana, need to understand how does this effects the flow.
Linked issue comments: user: login: elasticmachine
created_at: 2025-03-13T10:12:28Z
body: Pinging @elastic/kibana-cloud-security-posture (Team:Cloud Security)
author_association: CONTRIBUTOR

Diff:
diff --git a/src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts b/src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts
index ebaf5536a69fc..1cbcd538a9ace 100644
--- a/src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts
+++ b/src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts
@@ -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;
 }
diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
index 68d3d6d3b7a46..e23905a2dee5d 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
@@ -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}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.test.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.test.ts
index 873fb395ff1ef..9e92a60ee3338 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.test.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.test.ts
@@ -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 });
-    });
-
-    expect(toastsMock.addDanger).toHaveBeenCalledWith(
-      expect.objectContaining({
-        title: expect.any(String),
-        text: expect.any(String),
-      })
-    );
-    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 });
+      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();
-
-    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);
   });
 });
diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.ts
index 0078c40588bbd..be90710033869 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.ts
+++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/hooks/use_dynamic_entity_flyout.ts
@@ -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;
     }
 
diff --git a/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/types/generic_entity_record.ts b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/types/generic_entity_record.ts
new file mode 100644
index 0000000000000..fa35fc6beed3e
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/asset_inventory/types/generic_entity_record.ts
@@ -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;
+}
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.test.tsx
index 5753c9a153d15..3d3027d649034 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.test.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.test.tsx
@@ -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();
+  });
 });
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
index 8653afa2f2d81..cb9e3bf5c7ebf 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
@@ -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 sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
-  sort: {
-    field: 'key',
-    direction: 'asc',
-  },
-};
+  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
+  });
 
-const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
-  initialPageSize: 100,
-  showPerPageOptions: false,
+  return sortedItems;
 };
 
-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 }));
+interface FieldsTableProps {
+  /**
+   * The document object containing the fields and values to be displayed in the table.
+   */
+  document: Record<string, unknown>;
+
+  /**
+   * 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 }}
+    />
+  );
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/constants.ts
new file mode 100644
index 0000000000000..f9ccd698ebba4
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/constants.ts
@@ -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',
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
index 032f5b0e9842b..db3940e5b718e 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
@@ -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>
   );
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header.tsx
index 4ee8428480144..fb1b189da92e7 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header.tsx
@@ -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>
     </>
   );
 };
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header_data_cards.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header_data_cards.tsx
index 7bc0bc16e6ea6..c858f8ca9efd3 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header_data_cards.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/header_data_cards.tsx
@@ -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>(
@@ -87,24 +87,24 @@ export const HeaderDataCards = ({
       },
       {
         title: i18n.translate(
-          'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.categoryLabel',
+          'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.typeLabel',
           {
-            defaultMessage: 'Category',
+            defaultMessage: 'Type',
           }
         ),
-        description: <EuiTextTruncate text={category || ''} />,
+        description: <EuiTextTruncate text={type || ''} />,
       },
       {
         title: i18n.translate(
-          'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.typeLabel',
+          'xpack.securitySolution.universalEntityFlyout.flyoutHeader.headerDataBoxes.subtypeLabel',
           {
-            defaultMessage: 'Type',
+            defaultMessage: 'Sub Type',
           }
         ),
-        description: <EuiTextTruncate text={type || ''} />,
+        description: <EuiTextTruncate text={subType || ''} />,
       },
     ],
-    [selectValue, id, category, type]
+    [selectValue, id, subType, type]
   );
 
   return <ResponsiveDataCards cards={cards} collapseWidth={750} />;
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/hooks/use_get_generic_entity.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/hooks/use_get_generic_entity.ts
new file mode 100644
index 0000000000000..4771203dce45a
--- /dev/null
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/hooks/use_get_generic_entity.ts
@@ -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
+  });
+};
diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/index.tsx
index dc6a3a13d1b08..7b16f2d7e705a 100644
--- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/index.tsx
+++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/index.tsx
@@ -7,20 +7,37 @@
 
 import React, { useEffect } from 'react';
 import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
+import { METRIC_TYPE } from '@kbn/analytics';
 import {
-  uiMetricService,
   UNIVERSAL_ENTITY_FLYOUT_OPENED,
+  uiMetricService,
 } 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 { 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]);
+
+  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 docTimestamp = source?.['@timestamp'];
-  const timestamp = isDate(docTimestamp) ? docTimestamp : 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} />
     </>
   );


Reviews:
user: login: JordanSh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:12:41Z
user: login: JordanSh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T18:16:45Z
user: login: opauloh
body: Looks good, left a few minor suggestions
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T21:22:15Z
user: login: albertoblaz
body: LGTM! Only had a couple comments
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T09:27:46Z
user: login: tiansivive
body: LGTM from EA
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:18:40Z
user: login: JordanSh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:32:35Z
user: login: JordanSh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T12:33:07Z
user: login: opauloh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-01T18:14:19Z
user: login: semd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:11:51Z
user: login: semd
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:11:59Z
user: login: kqualters-elastic
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T11:48:32Z

Review comments:
diff_hunk: @@ -167,13 +150,14 @@ export const AssetInventoryDataTable = ({ state }: AssetInventoryDataTableProps)
     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;
path: x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
user: login: JordanSh
body: asserting since `DataTableRecord` does not accept a generic. Could edit it or use a custom type by omitting some fields, but that feels out of scope
created_at: 2025-03-26T18:12:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,20 +7,37 @@
 
 import React, { useEffect } from 'react';
 import type { FlyoutPanelProps } from '@kbn/expandable-flyout';
+import { METRIC_TYPE } from '@kbn/analytics';
 import {
-  uiMetricService,
   UNIVERSAL_ENTITY_FLYOUT_OPENED,
+  uiMetricService,
 } 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 { 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 {}
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/index.tsx
user: login: JordanSh
body: ignoring since this function is attempting to check if specific values exist on an unknown argument, there are ways around it but it becomes too messy and unreadable, worth the ignore imo
created_at: 2025-03-26T18:16:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,9 +27,12 @@ export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutCo
           />
         }
         expanded
-        localStorageKey={'universal_flyout:overview:fields_table'}
+        localStorageKey={'universal_flyout:overview:fields_section'}
       >
-        <FieldsTable document={source || {}} />
+        <FieldsTable
+          document={source || {}}
+          tableStorageKey={'universal_flyout:overview:fields_table_pins'}
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
user: login: opauloh
body: I think for better maintenance, we could create a constants file in the `universal_right` folder and keep the local storage keys there, WDYT?
created_at: 2025-03-31T16:38:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
-  sort: {
-    field: 'key',
-    direction: 'asc',
-  },
-};
+  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
+  });
 
-const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
-  initialPageSize: 100,
-  showPerPageOptions: false,
+  return sortedItems;
 };
 
-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 }));
+interface FieldsTableProps {
+  /**
+   * The document object containing the fields and values to be displayed in the table.
+   */
+  document: Record<string, unknown>;
+
+  /**
+   * 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 }) => {
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
user: login: opauloh
body: Great work on the `<FieldsTable/>` component

As this component aims to be reusable across Security solution components, a suggestion that could improve reusability and testability is to consider abstracting the storage logic so the component doesn't depend directly on `localStorage`. Instead, the consumer can pass a [storage wrapper](https://github.com/elastic/kibana/blob/main/src/platform/plugins/shared/kibana_utils/public/storage/storage.ts) instance via props (i.e. something implementing get(key) / set(key, value) / remove(key)), allowing flexibility to support `localStorage`, `sessionStorage`, or even an in-memory fallback for testing environments.

This would, IMO allow the component to be more flexible and improve testability (as mocking the storage would be simple):


Example of usage:

Consumers can Instantiate storage directly:

```tsx
import { Storage } from '@kbn/kibana-utils-plugin/public';

const myStorage = new Storage(window.localStorage);


<FieldsTable
  document={myDoc}
  tableStorageKey="my-key"
  storage={myStorage}
/>
```


Or retrieve storage from the `useKibana` hook:


```tsx
const { storage } = useKibana().services;

<FieldsTable
  document={myDoc}
  tableStorageKey="my-key"
  storage={storage}
/>
```

created_at: 2025-03-31T21:13:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
-  sort: {
-    field: 'key',
-    direction: 'asc',
-  },
-};
+  const sortedItems = [...flattenedItems].sort((a, b) => {
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
user: login: albertoblaz
body: nit: can't we just use `flattenedItems` as is? The previous map returns a new array

```suggestion
  const sortedItems = flattenedItems.sort((a, b) => {
```
created_at: 2025-04-01T08:52:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,9 +27,12 @@ export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutCo
           />
         }
         expanded
-        localStorageKey={'universal_flyout:overview:fields_table'}
+        localStorageKey={'universal_flyout:overview:fields_section'}
       >
-        <FieldsTable document={source || {}} />
+        <FieldsTable
+          document={source || {}}
+          tableStorageKey={'universal_flyout:overview:fields_table_pins'}
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
user: login: albertoblaz
body: I agree
created_at: 2025-04-01T09:08:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
-  sort: {
-    field: 'key',
-    direction: 'asc',
-  },
-};
+  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
+  });
 
-const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
-  initialPageSize: 100,
-  showPerPageOptions: false,
+  return sortedItems;
 };
 
-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 }));
+interface FieldsTableProps {
+  /**
+   * The document object containing the fields and values to be displayed in the table.
+   */
+  document: Record<string, unknown>;
+
+  /**
+   * 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 }) => {
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
user: login: albertoblaz
body: This is a great idea though I don't see it critical. If @JordanSh prefers to ship this as is now and create a ticket to refactor it later, I think that would make sense too. Your call
created_at: 2025-04-01T09:27:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 sorting: EuiInMemoryTableProps<FlattenedItem>['sorting'] = {
-  sort: {
-    field: 'key',
-    direction: 'asc',
-  },
-};
+  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
+  });
 
-const pagination: EuiInMemoryTableProps<FlattenedItem>['pagination'] = {
-  initialPageSize: 100,
-  showPerPageOptions: false,
+  return sortedItems;
 };
 
-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 }));
+interface FieldsTableProps {
+  /**
+   * The document object containing the fields and values to be displayed in the table.
+   */
+  document: Record<string, unknown>;
+
+  /**
+   * 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 }) => {
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/components/fields_table.tsx
user: login: JordanSh
body: Thanks @opauloh, this is a very interesting idea indeed. But as @albertoblaz said I don't see it as an urgent need. tbh, my assumption is that most consumers wouldn't wont be bothered by passing storage instances just to use this feature, and that 9 out of 10 times it would be the localStorage anyway. 

For now I think it adds more complexity than what is required. But I'd be happy to follow up with a another ticket if the need rises, or if you think its required at the moment. Does this approach makes sense to you? It's very possible I'm missing some clear needs so please let me know what you think
created_at: 2025-04-01T12:32:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -27,9 +27,12 @@ export const UniversalEntityFlyoutContent = ({ source }: UniversalEntityFlyoutCo
           />
         }
         expanded
-        localStorageKey={'universal_flyout:overview:fields_table'}
+        localStorageKey={'universal_flyout:overview:fields_section'}
       >
-        <FieldsTable document={source || {}} />
+        <FieldsTable
+          document={source || {}}
+          tableStorageKey={'universal_flyout:overview:fields_table_pins'}
path: x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/universal_right/content.tsx
user: login: JordanSh
body: 100% thanks for catching that
created_at: 2025-04-01T12:33:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 {
path: src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts
user: login: opauloh
body: @JordanSh I see the Threat Hunting approval is required for this change as it's a codeowner for the `kbn-securitysolution-ecs` package, but I guess it would make sense to include our team to be a co-owner of the  `src/platform/packages/shared/kbn-securitysolution-ecs/src/entity` path if the @elastic/security-threat-hunting doesn't oppose to this
created_at: 2025-04-01T18:14:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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 {
path: src/platform/packages/shared/kbn-securitysolution-ecs/src/entity/index.ts
user: login: semd
body: Sounds good to me
created_at: 2025-04-02T11:11:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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) => {
path: x-pack/solutions/security/plugins/security_solution/public/asset_inventory/components/asset_inventory_data_table.tsx
user: login: kqualters-elastic
body: should probably wrap this and every other function in this file in useCallback
created_at: 2025-04-02T11:48:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: elasticmachine
created_at: 2025-03-27T10:51:45Z
body: Pinging @elastic/kibana-cloud-security-posture (Team:Cloud Security)
author_association: CONTRIBUTOR
user: login: elasticmachine
created_at: 2025-04-02T12:58:08Z
body: ## :yellow_heart: Build succeeded, but was flaky
* [Buildkite Build](https://buildkite.com/elastic/kibana-pull-request/builds/289435)
* Commit: bc2d090cdf490b59921baaaa44b0a46e6a3c7ac9

### Failed CI Steps
* [FTR Configs #4](https://buildkite.com/elastic/kibana-pull-request/builds/289435#0195f652-122e-49a6-a448-3019279837db)

### Test Failures
* [[job]](https://buildkite.com/elastic/kibana-pull-request/builds/289435#0195f652-122e-49a6-a448-3019279837db) [[logs]](https://buildkite.com/organizations/elastic/pipelines/kibana-pull-request/builds/289435/jobs/0195f652-122e-49a6-a448-3019279837db/artifacts/0195f661-1949-4404-9b5e-aae8f99daf51) FTR Configs #<span></span>4 / Stateful Observability - Deployment-agnostic API integration tests Dataset quality Integrations gets the custom installed integrations returns custom integrations and its datasets map

### Metrics [[docs](https://www.elastic.co/guide/en/kibana/master/ci-metrics.html)]
#### Module Count
> Fewer modules leads to a faster build time


| id | [before](https://github.com/elastic/kibana/commit/9797e95289125967d6c5bc2d66bb70b927a50b8d) | [after](https://github.com/elastic/kibana/commit/bc2d090cdf490b59921baaaa44b0a46e6a3c7ac9) | diff |
| --- | --- | --- | --- |
| `securitySolution` | 7171 | 7173 | +2 |

#### Async chunks
> Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app


| id | [before](https://github.com/elastic/kibana/commit/9797e95289125967d6c5bc2d66bb70b927a50b8d) | [after](https://github.com/elastic/kibana/commit/bc2d090cdf490b59921baaaa44b0a46e6a3c7ac9) | diff |
| --- | --- | --- | --- |
| `securitySolution` | 8.9MB | 8.9MB | +2.0KB |



### History
* :green_heart: [Build #289108](https://buildkite.com/elastic/kibana-pull-request/builds/289108) succeeded 3769a81a355a61d3874759836b6b8ab19e330102
* :broken_heart: [Build #289019](https://buildkite.com/elastic/kibana-pull-request/builds/289019) failed 3769a81a355a61d3874759836b6b8ab19e330102
* :broken_heart: [Build #289014](https://buildkite.com/elastic/kibana-pull-request/builds/289014) failed 0e630d3178eebfed3375faa6d7f2a8bc6d447e21
* :broken_heart: [Build #289002](https://buildkite.com/elastic/kibana-pull-request/builds/289002) failed ab9d42df2431cbcb21aca48f38084c57c84bad7b
* :yellow_heart: [Build #288695](https://buildkite.com/elastic/kibana-pull-request/builds/288695) was flaky 639f429a2695ef5140930132c50ad94b6d9049a4
* :broken_heart: [Build #288318](https://buildkite.com/elastic/kibana-pull-request/builds/288318) failed 556ba88b7826258441f8ced9a7038b03effb4a56

cc @JordanSh

<!--buildkite-pr-comment-kibana-pull-request
{"builds":[{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289435","number":289435,"commit":"bc2d090cdf490b59921baaaa44b0a46e6a3c7ac9"},{"buildStatus":{"state":"passed","success":true,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289108","number":289108,"commit":"3769a81a355a61d3874759836b6b8ab19e330102"},{"buildStatus":{"state":"failing","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289019","number":289019,"commit":"3769a81a355a61d3874759836b6b8ab19e330102"},{"buildStatus":{"state":"failing","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289014","number":289014,"commit":"0e630d3178eebfed3375faa6d7f2a8bc6d447e21"},{"buildStatus":{"state":"failing","success":false,"hasRetries":false,"hasNonPreemptionRetries":false},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/289002","number":289002,"commit":"ab9d42df2431cbcb21aca48f38084c57c84bad7b"},{"buildStatus":{"state":"passed","success":true,"hasRetries":true,"hasNonPreemptionRetries":true},"url":"https://buildkite.com/elastic/kibana-pull-request/builds/288695","number":288695,"commit":"639f429a2695ef5140930132c50ad94b6d9049a4"}],"number":289435}
buildkite-pr-comment-->
author_association: CONTRIBUTOR